From a0efab07772bffc6b55834e6b56ef8ce4efa502a Mon Sep 17 00:00:00 2001
From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com>
Date: Thu, 18 Jun 2026 10:30:26 -0300
Subject: [PATCH 1/6] implement: control-suggestion-state-ui
---
.../control-implementations/IndexView.vue | 133 ++++++++++++
.../__tests__/IndexView.spec.ts | 199 ++++++++++++++++++
.../ControlImplementationSuggestions.vue | 171 +++++++++++++++
.../ControlImplementationSuggestions.spec.ts | 116 ++++++++++
.../__tests__/dashboard-suggestions.spec.ts | 9 +
.../partials/dashboard-suggestions.ts | 17 ++
6 files changed, 645 insertions(+)
create mode 100644 src/views/control-implementations/partials/ControlImplementationSuggestions.vue
create mode 100644 src/views/control-implementations/partials/__tests__/ControlImplementationSuggestions.spec.ts
diff --git a/src/views/control-implementations/IndexView.vue b/src/views/control-implementations/IndexView.vue
index ce998954..43f252ac 100644
--- a/src/views/control-implementations/IndexView.vue
+++ b/src/views/control-implementations/IndexView.vue
@@ -141,6 +141,15 @@
position="right"
class="w-full! md:w-1/2! lg:w-3/5!"
>
+
+
Components
uiStore.controlImplementationDrawerOpen,
@@ -272,8 +290,19 @@ const { data: sspRisks, execute: loadSspRisks } = useDataApi(
{},
{ immediate: false },
);
+const {
+ data: pendingDashboardSuggestions,
+ execute: fetchPendingDashboardSuggestions,
+ isLoading: pendingDashboardSuggestionsLoading,
+} = useDataApi(null, {}, { immediate: false });
+const {
+ data: dashboardSuggestionControlResults,
+ execute: fetchDashboardSuggestionControlResults,
+ isLoading: dashboardSuggestionControlResultsLoading,
+} = useDataApi(null, {}, { immediate: false });
const nodes = ref>([]);
+const loadedDashboardSuggestionStateFor = ref(null);
interface StatementSuggestionWorkItem {
requirement: ImplementedRequirement;
@@ -303,6 +332,47 @@ const bulkSuggestionsButtonLabel = computed(() => {
return 'Apply All Suggestions';
});
+const pendingDashboardSuggestionsByControl = computed(() => {
+ const grouped: Record = {};
+ for (const suggestion of pendingDashboardSuggestions.value ?? []) {
+ const key = normalizeId(suggestion.controlId);
+ if (!key) {
+ continue;
+ }
+ grouped[key] = grouped[key] ?? [];
+ grouped[key].push(suggestion);
+ }
+ return grouped;
+});
+
+const dashboardSuggestionResultsByControl = computed(() => {
+ const results: Record = {};
+ for (const result of dashboardSuggestionControlResults.value ?? []) {
+ const key = normalizeId(result.controlId);
+ if (!key) {
+ continue;
+ }
+ results[key] = result;
+ }
+ return results;
+});
+
+const selectedControlDashboardSuggestions = computed(() => {
+ const key = normalizeId(selectedImplementedRequirement.value?.controlId);
+ return key ? (pendingDashboardSuggestionsByControl.value[key] ?? []) : [];
+});
+
+const selectedControlSuggestionResult = computed(() => {
+ const key = normalizeId(selectedImplementedRequirement.value?.controlId);
+ return key ? dashboardSuggestionResultsByControl.value[key] : undefined;
+});
+
+const dashboardSuggestionStateLoading = computed(
+ () =>
+ pendingDashboardSuggestionsLoading.value ||
+ dashboardSuggestionControlResultsLoading.value,
+);
+
function normalizeId(value?: string): string {
return (value || '').trim().toLowerCase();
}
@@ -377,6 +447,45 @@ function controlHighestSeverity(
return stats.highestSeverity ?? 'high';
}
+async function loadDashboardSuggestionState(force = false) {
+ const sspId = systemStore.system.securityPlan?.uuid;
+ if (!sspId || !aiConfigStore.dashboardSuggestionsEnabled) {
+ pendingDashboardSuggestions.value = [];
+ dashboardSuggestionControlResults.value = [];
+ loadedDashboardSuggestionStateFor.value = null;
+ return;
+ }
+
+ if (!force && loadedDashboardSuggestionStateFor.value === sspId) {
+ return;
+ }
+
+ const [pendingResult, controlResultsResult] = await Promise.allSettled([
+ fetchPendingDashboardSuggestions(
+ buildDashboardSuggestionsEndpoint(sspId, 'pending'),
+ {
+ camelcaseStopPaths: [
+ 'data.labelSet',
+ 'data.proposedFilterLabelSet',
+ 'data.originalProposedFilterLabelSet',
+ ],
+ },
+ ),
+ fetchDashboardSuggestionControlResults(
+ buildDashboardSuggestionControlResultsEndpoint(sspId),
+ ),
+ ]);
+
+ if (pendingResult.status === 'rejected') {
+ pendingDashboardSuggestions.value = [];
+ }
+ if (controlResultsResult.status === 'rejected') {
+ dashboardSuggestionControlResults.value = [];
+ }
+
+ loadedDashboardSuggestionStateFor.value = sspId;
+}
+
function openControlRisks(controlId?: string) {
if (!controlId) {
return;
@@ -830,6 +939,7 @@ function openImplementationDrawer(req: ImplementedRequirement | undefined) {
uiStore.setControlImplementationDrawerOpen(true);
uiStore.setControlImplementationSelectedRequirementId(req.uuid);
selectedImplementedRequirement.value = req;
+ void loadDashboardSuggestionState();
}
watch(
@@ -838,6 +948,9 @@ watch(
if (!sspId) {
sspRisks.value = [];
loadedSspRisksFor.value = null;
+ pendingDashboardSuggestions.value = [];
+ dashboardSuggestionControlResults.value = [];
+ loadedDashboardSuggestionStateFor.value = null;
return;
}
@@ -858,6 +971,23 @@ watch(
{ immediate: true },
);
+watch(
+ () => [
+ systemStore.system.securityPlan?.uuid,
+ aiConfigStore.dashboardSuggestionsConfigFetched,
+ aiConfigStore.dashboardSuggestionsEnabled,
+ ],
+ ([sspId, configFetched, enabled]) => {
+ if (!sspId || !configFetched || !enabled) {
+ pendingDashboardSuggestions.value = [];
+ dashboardSuggestionControlResults.value = [];
+ loadedDashboardSuggestionStateFor.value = null;
+ return;
+ }
+ void loadDashboardSuggestionState();
+ },
+);
+
watch(
() => uiStore.controlImplementationDrawerOpen,
(isOpen) => {
@@ -869,6 +999,9 @@ watch(
);
onMounted(async () => {
+ await aiConfigStore.fetchDashboardSuggestionsConfig();
+ await loadDashboardSuggestionState();
+
try {
await loadProfileBindings();
if (profileBindings.value.length > 0) {
diff --git a/src/views/control-implementations/__tests__/IndexView.spec.ts b/src/views/control-implementations/__tests__/IndexView.spec.ts
index fa1bcb82..e20c3085 100644
--- a/src/views/control-implementations/__tests__/IndexView.spec.ts
+++ b/src/views/control-implementations/__tests__/IndexView.spec.ts
@@ -7,6 +7,19 @@ const listProfiles = vi.fn();
const axiosGet = vi.fn();
const loadRisks = vi.fn(async () => ({ data: { value: { data: [] } } }));
const fetchControlImplementations = vi.fn();
+const fetchPendingDashboardSuggestions = vi.fn();
+const fetchDashboardSuggestionControlResults = vi.fn();
+let dashboardSuggestionsEnabled = false;
+let dashboardSuggestionsConfigFetched = false;
+const fetchDashboardSuggestionsConfig = vi.fn(async () => {
+ dashboardSuggestionsConfigFetched = true;
+ return dashboardSuggestionsEnabled;
+});
+let pendingDashboardSuggestionsFixture: unknown[] = [];
+let controlResultsFixture: unknown[] = [];
+let pendingDashboardSuggestionsReject = false;
+let controlResultsReject = false;
+let useDataApiNullCallIndex = 0;
const uiStore = {
controlImplementationDrawerOpen: false,
controlImplementationSelectedRequirementId: null as string | null,
@@ -40,7 +53,24 @@ vi.mock('@/stores/ui.ts', () => ({
useUIStore: () => uiStore,
}));
+vi.mock('@/stores/ai-config', () => ({
+ useAiConfigStore: () => ({
+ get dashboardSuggestionsEnabled() {
+ return dashboardSuggestionsEnabled;
+ },
+ get dashboardSuggestionsConfigFetched() {
+ return dashboardSuggestionsConfigFetched;
+ },
+ fetchDashboardSuggestionsConfig,
+ }),
+}));
+
vi.mock('vue-router', () => ({
+ RouterLink: {
+ name: 'RouterLink',
+ props: ['to'],
+ template: '',
+ },
useRouter: () => ({ push: vi.fn() }),
}));
@@ -69,6 +99,38 @@ vi.mock('@/composables/axios', () => ({
execute: fetchControlImplementations,
};
}
+ if (url === null) {
+ const callIndex = useDataApiNullCallIndex;
+ useDataApiNullCallIndex += 1;
+ if (callIndex % 3 === 1) {
+ const data = ref([]);
+ return {
+ data,
+ isLoading: ref(false),
+ error: ref(null),
+ execute: async (...args: unknown[]) => {
+ const response = await fetchPendingDashboardSuggestions(...args);
+ data.value = response.data.value.data;
+ return response;
+ },
+ };
+ }
+ if (callIndex % 3 === 2) {
+ const data = ref([]);
+ return {
+ data,
+ isLoading: ref(false),
+ error: ref(null),
+ execute: async (...args: unknown[]) => {
+ const response = await fetchDashboardSuggestionControlResults(
+ ...args,
+ );
+ data.value = response.data.value.data;
+ return response;
+ },
+ };
+ }
+ }
return {
data: ref([]),
isLoading: ref(false),
@@ -104,6 +166,7 @@ const stubs = {
RouterLink: { template: '' },
Message: { template: '
' },
Badge: { template: '{{ value }}', props: ['value'] },
+ Chip: { template: '{{ label }}', props: ['label'] },
Button: {
props: ['disabled', 'ariaLabel', 'title', 'label'],
emits: ['click'],
@@ -145,6 +208,13 @@ const stubs = {
describe('control implementations IndexView', () => {
beforeEach(() => {
vi.clearAllMocks();
+ useDataApiNullCallIndex = 0;
+ dashboardSuggestionsEnabled = false;
+ dashboardSuggestionsConfigFetched = false;
+ pendingDashboardSuggestionsFixture = [];
+ controlResultsFixture = [];
+ pendingDashboardSuggestionsReject = false;
+ controlResultsReject = false;
uiStore.controlImplementationDrawerOpen = false;
uiStore.controlImplementationSelectedRequirementId = null;
uiStore.controlImplementationExpandedKeys = {};
@@ -167,6 +237,30 @@ describe('control implementations IndexView', () => {
},
},
});
+ fetchPendingDashboardSuggestions.mockImplementation(async () => {
+ if (pendingDashboardSuggestionsReject) {
+ throw new Error('pending failed');
+ }
+ return {
+ data: {
+ value: {
+ data: pendingDashboardSuggestionsFixture,
+ },
+ },
+ };
+ });
+ fetchDashboardSuggestionControlResults.mockImplementation(async () => {
+ if (controlResultsReject) {
+ throw new Error('results failed');
+ }
+ return {
+ data: {
+ value: {
+ data: controlResultsFixture,
+ },
+ },
+ };
+ });
});
it('disables the implementation eye button when a control has no implementation', async () => {
@@ -201,4 +295,109 @@ describe('control implementations IndexView', () => {
uiStore.setControlImplementationSelectedRequirementId,
).toHaveBeenCalledWith('req-1');
});
+
+ it('does not render the AI dashboard suggestions panel when the feature flag is disabled', async () => {
+ const wrapper = mount(IndexView, { global: { stubs } });
+ await waitForMountedControls();
+
+ const implementationButton = wrapper
+ .findAll('button')
+ .find((button) => button.attributes('title') === 'View implementation');
+ await implementationButton?.trigger('click');
+ await flushPromises();
+
+ expect(wrapper.text()).not.toContain('AI dashboard suggestions');
+ expect(fetchPendingDashboardSuggestions).not.toHaveBeenCalled();
+ expect(fetchDashboardSuggestionControlResults).not.toHaveBeenCalled();
+ });
+
+ it('matches pending suggestions to the selected control case-insensitively', async () => {
+ dashboardSuggestionsEnabled = true;
+ pendingDashboardSuggestionsFixture = [
+ {
+ id: 'suggestion-1',
+ status: 'pending',
+ controlId: 'AC-1',
+ labelSetHash: 'hash-1',
+ proposedFilterName: 'Production evidence',
+ proposedFilterLabelSet: { env: 'prod' },
+ confidence: 0.8,
+ reasoning: 'Uppercase control id still matches.',
+ },
+ ];
+
+ const wrapper = mount(IndexView, { global: { stubs } });
+ await waitForMountedControls();
+
+ const implementationButton = wrapper
+ .findAll('button')
+ .find((button) => button.attributes('title') === 'View implementation');
+ await implementationButton?.trigger('click');
+ await flushPromises();
+
+ expect(wrapper.text()).toContain('AI dashboard suggestions');
+ expect(wrapper.text()).toContain('Production evidence');
+ expect(wrapper.text()).toContain('80% confidence');
+ expect(fetchPendingDashboardSuggestions).toHaveBeenCalledWith(
+ expect.stringContaining('/dashboard-suggestions?status=pending'),
+ expect.objectContaining({
+ camelcaseStopPaths: expect.arrayContaining([
+ 'data.proposedFilterLabelSet',
+ ]),
+ }),
+ );
+ expect(fetchDashboardSuggestionControlResults).toHaveBeenCalledWith(
+ expect.stringContaining('/dashboard-suggestions/control-results'),
+ );
+ });
+
+ it('keeps pending suggestions visible when control-results cannot be fetched', async () => {
+ dashboardSuggestionsEnabled = true;
+ controlResultsReject = true;
+ pendingDashboardSuggestionsFixture = [
+ {
+ id: 'suggestion-1',
+ status: 'pending',
+ controlId: 'AC-1',
+ labelSetHash: 'hash-1',
+ proposedFilterName: 'Resilient suggestion',
+ },
+ ];
+
+ const wrapper = mount(IndexView, { global: { stubs } });
+ await waitForMountedControls();
+
+ const implementationButton = wrapper
+ .findAll('button')
+ .find((button) => button.attributes('title') === 'View implementation');
+ await implementationButton?.trigger('click');
+ await flushPromises();
+
+ expect(wrapper.text()).toContain('Resilient suggestion');
+ });
+
+ it('keeps no-match state visible when pending suggestions cannot be fetched', async () => {
+ dashboardSuggestionsEnabled = true;
+ pendingDashboardSuggestionsReject = true;
+ controlResultsFixture = [
+ {
+ controlId: 'AC-1',
+ outcome: 'no_match',
+ evaluatedAt: '2026-06-18T10:30:00Z',
+ },
+ ];
+
+ const wrapper = mount(IndexView, { global: { stubs } });
+ await waitForMountedControls();
+
+ const implementationButton = wrapper
+ .findAll('button')
+ .find((button) => button.attributes('title') === 'View implementation');
+ await implementationButton?.trigger('click');
+ await flushPromises();
+
+ expect(wrapper.text()).toContain(
+ 'AI reviewed this control and found no matching dashboard filter',
+ );
+ });
});
diff --git a/src/views/control-implementations/partials/ControlImplementationSuggestions.vue b/src/views/control-implementations/partials/ControlImplementationSuggestions.vue
new file mode 100644
index 00000000..00ed6cb6
--- /dev/null
+++ b/src/views/control-implementations/partials/ControlImplementationSuggestions.vue
@@ -0,0 +1,171 @@
+
+
+
+
+
+
AI dashboard suggestions
+
+ Review in dashboard suggestions
+
+
+
+
+
+
+ Loading AI dashboard suggestions...
+
+
+
+
+
+
+
+
+ {{ filterName(suggestion) }}
+
+
+ {{ confidenceLabel(suggestion.confidence) }}
+
+
+
+
+
+
+
+
+
+
+ {{ suggestion.reasoning }}
+
+
+
+
+
+
+
+ AI reviewed this control and found no matching dashboard filter
+ on {{ formatEvaluatedAt(result?.evaluatedAt) }}.
+
+
+
+ {{
+ matchedWithoutPending
+ ? 'No pending AI dashboard suggestions for this control.'
+ : "AI hasn't evaluated this control yet."
+ }}
+
+
+
diff --git a/src/views/control-implementations/partials/__tests__/ControlImplementationSuggestions.spec.ts b/src/views/control-implementations/partials/__tests__/ControlImplementationSuggestions.spec.ts
new file mode 100644
index 00000000..1e029c66
--- /dev/null
+++ b/src/views/control-implementations/partials/__tests__/ControlImplementationSuggestions.spec.ts
@@ -0,0 +1,116 @@
+import { describe, expect, it } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { defineComponent } from 'vue';
+import ControlImplementationSuggestions from '../ControlImplementationSuggestions.vue';
+import type {
+ ControlSuggestionResult,
+ DashboardSuggestion,
+} from '@/views/dashboard/partials/dashboard-suggestions';
+
+function suggestion(
+ overrides: Partial = {},
+): DashboardSuggestion {
+ return {
+ id: 'suggestion-1',
+ status: 'pending',
+ controlId: 'AC-1',
+ labelSetHash: 'hash-1',
+ proposedFilterName: 'Production evidence',
+ proposedFilterLabelSet: {
+ env: 'prod',
+ service: 'api',
+ _policy: 'internal',
+ },
+ confidence: 0.91,
+ reasoning: 'The production API evidence maps cleanly to this control.',
+ ...overrides,
+ };
+}
+
+function mountComponent(props: {
+ suggestions?: DashboardSuggestion[];
+ result?: ControlSuggestionResult;
+ loading?: boolean;
+}) {
+ return mount(ControlImplementationSuggestions, {
+ props: {
+ controlId: 'ac-1',
+ sspId: 'ssp-1',
+ suggestions: [],
+ ...props,
+ },
+ global: {
+ stubs: {
+ Chip: {
+ props: ['label'],
+ template: '{{ label }}',
+ },
+ Message: {
+ template: '
',
+ },
+ RouterLink: defineComponent({
+ name: 'RouterLink',
+ props: ['to'],
+ template: '',
+ }),
+ },
+ },
+ });
+}
+
+describe('ControlImplementationSuggestions', () => {
+ it('renders a loading state while AI state is loading', () => {
+ expect(mountComponent({ suggestions: [], loading: true }).text()).toContain(
+ 'Loading AI dashboard suggestions...',
+ );
+ });
+
+ it('lists pending suggestions with labels, confidence, reasoning, and review link', () => {
+ const wrapper = mountComponent({ suggestions: [suggestion()] });
+
+ expect(wrapper.text()).toContain('AI dashboard suggestions');
+ expect(wrapper.text()).toContain('Production evidence');
+ expect(wrapper.text()).toContain('env=prod');
+ expect(wrapper.text()).toContain('service=api');
+ expect(wrapper.text()).not.toContain('_policy=internal');
+ expect(wrapper.text()).toContain('91% confidence');
+ expect(wrapper.text()).toContain(
+ 'The production API evidence maps cleanly to this control.',
+ );
+ expect(wrapper.find('[data-test="review-link"]').exists()).toBe(true);
+ expect(wrapper.findComponent({ name: 'RouterLink' }).props('to')).toEqual({
+ name: 'dashboards.suggestions',
+ params: { sspId: 'ssp-1' },
+ });
+ });
+
+ it('renders evaluated no-match state with the run date', () => {
+ const evaluatedAt = '2026-06-18T10:30:00Z';
+ const wrapper = mountComponent({
+ result: {
+ controlId: 'AC-2',
+ outcome: 'no_match',
+ evaluatedAt,
+ },
+ });
+
+ expect(wrapper.text()).toContain(
+ 'AI reviewed this control and found no matching dashboard filter',
+ );
+ expect(wrapper.text()).toContain(new Date(evaluatedAt).toLocaleString());
+ });
+
+ it('renders subtle empty states for not evaluated and matched-without-pending controls', () => {
+ expect(mountComponent({}).text()).toContain(
+ "AI hasn't evaluated this control yet.",
+ );
+ expect(
+ mountComponent({
+ result: {
+ controlId: 'AC-1',
+ outcome: 'matched',
+ },
+ }).text(),
+ ).toContain('No pending AI dashboard suggestions for this control.');
+ });
+});
diff --git a/src/views/dashboard/partials/__tests__/dashboard-suggestions.spec.ts b/src/views/dashboard/partials/__tests__/dashboard-suggestions.spec.ts
index 0dab0e8a..6a2dbad1 100644
--- a/src/views/dashboard/partials/__tests__/dashboard-suggestions.spec.ts
+++ b/src/views/dashboard/partials/__tests__/dashboard-suggestions.spec.ts
@@ -1,10 +1,19 @@
import { describe, expect, it } from 'vitest';
import {
+ buildDashboardSuggestionControlResultsEndpoint,
buildLabelFilter,
computeGroupEditDiff,
type DashboardSuggestion,
} from '../dashboard-suggestions';
+describe('buildDashboardSuggestionControlResultsEndpoint', () => {
+ it('builds the latest control-result endpoint for an SSP', () => {
+ expect(buildDashboardSuggestionControlResultsEndpoint('ssp 1')).toBe(
+ '/api/oscal/system-security-plans/ssp%201/dashboard-suggestions/control-results',
+ );
+ });
+});
+
describe('buildLabelFilter', () => {
it('returns undefined when there are no valid conditions', () => {
expect(buildLabelFilter([])).toBeUndefined();
diff --git a/src/views/dashboard/partials/dashboard-suggestions.ts b/src/views/dashboard/partials/dashboard-suggestions.ts
index f193acc9..6f028b57 100644
--- a/src/views/dashboard/partials/dashboard-suggestions.ts
+++ b/src/views/dashboard/partials/dashboard-suggestions.ts
@@ -157,6 +157,17 @@ export interface DashboardSuggestion {
updatedAt?: string;
}
+export type ControlSuggestionOutcome = 'matched' | 'no_match';
+
+export interface ControlSuggestionResult {
+ controlId: string;
+ controlCatalogId?: string;
+ outcome: ControlSuggestionOutcome;
+ suggestionCount?: number;
+ runId?: string;
+ evaluatedAt?: string;
+}
+
export type LabelChipKind = 'unchanged' | 'added' | 'removed';
export interface LabelChip {
@@ -333,6 +344,12 @@ export function buildDashboardSuggestionsEndpoint(
return `${dashboardSuggestionsBaseEndpoint(sspId)}/dashboard-suggestions${query}`;
}
+export function buildDashboardSuggestionControlResultsEndpoint(
+ sspId: string,
+): string {
+ return `${dashboardSuggestionsBaseEndpoint(sspId)}/dashboard-suggestions/control-results`;
+}
+
export function buildAcceptDashboardSuggestionsEndpoint(sspId: string): string {
return `${dashboardSuggestionsBaseEndpoint(sspId)}/dashboard-suggestions/accept`;
}
From c819d631cfb844bb3d8e05ce8ec0705c574bac9b Mon Sep 17 00:00:00 2001
From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com>
Date: Thu, 18 Jun 2026 10:36:29 -0300
Subject: [PATCH 2/6] self-review: address pass 1 findings
---
.../control-implementations/IndexView.vue | 73 +++++++++++-----
.../__tests__/IndexView.spec.ts | 87 ++++++++++++++++---
2 files changed, 124 insertions(+), 36 deletions(-)
diff --git a/src/views/control-implementations/IndexView.vue b/src/views/control-implementations/IndexView.vue
index 43f252ac..8f51b7a3 100644
--- a/src/views/control-implementations/IndexView.vue
+++ b/src/views/control-implementations/IndexView.vue
@@ -303,6 +303,8 @@ const {
const nodes = ref>([]);
const loadedDashboardSuggestionStateFor = ref(null);
+const loadingDashboardSuggestionStateFor = ref(null);
+let dashboardSuggestionStateLoadPromise: Promise | null = null;
interface StatementSuggestionWorkItem {
requirement: ImplementedRequirement;
@@ -453,6 +455,8 @@ async function loadDashboardSuggestionState(force = false) {
pendingDashboardSuggestions.value = [];
dashboardSuggestionControlResults.value = [];
loadedDashboardSuggestionStateFor.value = null;
+ loadingDashboardSuggestionStateFor.value = null;
+ dashboardSuggestionStateLoadPromise = null;
return;
}
@@ -460,30 +464,55 @@ async function loadDashboardSuggestionState(force = false) {
return;
}
- const [pendingResult, controlResultsResult] = await Promise.allSettled([
- fetchPendingDashboardSuggestions(
- buildDashboardSuggestionsEndpoint(sspId, 'pending'),
- {
- camelcaseStopPaths: [
- 'data.labelSet',
- 'data.proposedFilterLabelSet',
- 'data.originalProposedFilterLabelSet',
- ],
- },
- ),
- fetchDashboardSuggestionControlResults(
- buildDashboardSuggestionControlResultsEndpoint(sspId),
- ),
- ]);
-
- if (pendingResult.status === 'rejected') {
- pendingDashboardSuggestions.value = [];
- }
- if (controlResultsResult.status === 'rejected') {
- dashboardSuggestionControlResults.value = [];
+ if (
+ !force &&
+ loadingDashboardSuggestionStateFor.value === sspId &&
+ dashboardSuggestionStateLoadPromise
+ ) {
+ await dashboardSuggestionStateLoadPromise;
+ return;
}
- loadedDashboardSuggestionStateFor.value = sspId;
+ loadingDashboardSuggestionStateFor.value = sspId;
+ const loadPromise = (async () => {
+ const [pendingResult, controlResultsResult] = await Promise.allSettled([
+ fetchPendingDashboardSuggestions(
+ buildDashboardSuggestionsEndpoint(sspId, 'pending'),
+ {
+ camelcaseStopPaths: [
+ 'data.labelSet',
+ 'data.proposedFilterLabelSet',
+ 'data.originalProposedFilterLabelSet',
+ ],
+ },
+ ),
+ fetchDashboardSuggestionControlResults(
+ buildDashboardSuggestionControlResultsEndpoint(sspId),
+ ),
+ ]);
+
+ if (pendingResult.status === 'rejected') {
+ pendingDashboardSuggestions.value = [];
+ }
+ if (controlResultsResult.status === 'rejected') {
+ dashboardSuggestionControlResults.value = [];
+ }
+
+ loadedDashboardSuggestionStateFor.value = sspId;
+ })();
+ dashboardSuggestionStateLoadPromise = loadPromise;
+
+ try {
+ await loadPromise;
+ } finally {
+ if (
+ loadingDashboardSuggestionStateFor.value === sspId &&
+ dashboardSuggestionStateLoadPromise === loadPromise
+ ) {
+ loadingDashboardSuggestionStateFor.value = null;
+ dashboardSuggestionStateLoadPromise = null;
+ }
+ }
}
function openControlRisks(controlId?: string) {
diff --git a/src/views/control-implementations/__tests__/IndexView.spec.ts b/src/views/control-implementations/__tests__/IndexView.spec.ts
index e20c3085..1a723fcf 100644
--- a/src/views/control-implementations/__tests__/IndexView.spec.ts
+++ b/src/views/control-implementations/__tests__/IndexView.spec.ts
@@ -1,19 +1,23 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-import { mount } from '@vue/test-utils';
-import { defineComponent, h, ref } from 'vue';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { enableAutoUnmount, mount } from '@vue/test-utils';
+import { defineComponent, h, reactive, ref } from 'vue';
import IndexView from '../IndexView.vue';
+enableAutoUnmount(afterEach);
+
const listProfiles = vi.fn();
const axiosGet = vi.fn();
const loadRisks = vi.fn(async () => ({ data: { value: { data: [] } } }));
const fetchControlImplementations = vi.fn();
const fetchPendingDashboardSuggestions = vi.fn();
const fetchDashboardSuggestionControlResults = vi.fn();
-let dashboardSuggestionsEnabled = false;
-let dashboardSuggestionsConfigFetched = false;
+const aiConfigState = reactive({
+ dashboardSuggestionsEnabled: false,
+ dashboardSuggestionsConfigFetched: false,
+});
const fetchDashboardSuggestionsConfig = vi.fn(async () => {
- dashboardSuggestionsConfigFetched = true;
- return dashboardSuggestionsEnabled;
+ aiConfigState.dashboardSuggestionsConfigFetched = true;
+ return aiConfigState.dashboardSuggestionsEnabled;
});
let pendingDashboardSuggestionsFixture: unknown[] = [];
let controlResultsFixture: unknown[] = [];
@@ -56,10 +60,10 @@ vi.mock('@/stores/ui.ts', () => ({
vi.mock('@/stores/ai-config', () => ({
useAiConfigStore: () => ({
get dashboardSuggestionsEnabled() {
- return dashboardSuggestionsEnabled;
+ return aiConfigState.dashboardSuggestionsEnabled;
},
get dashboardSuggestionsConfigFetched() {
- return dashboardSuggestionsConfigFetched;
+ return aiConfigState.dashboardSuggestionsConfigFetched;
},
fetchDashboardSuggestionsConfig,
}),
@@ -156,6 +160,16 @@ function flushPromises() {
return new Promise((resolve) => setTimeout(resolve, 0));
}
+function createDeferred() {
+ let resolve!: (value: T | PromiseLike) => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise((promiseResolve, promiseReject) => {
+ resolve = promiseResolve;
+ reject = promiseReject;
+ });
+ return { promise, resolve, reject };
+}
+
async function waitForMountedControls() {
for (let index = 0; index < 5; index += 1) {
await flushPromises();
@@ -209,8 +223,8 @@ describe('control implementations IndexView', () => {
beforeEach(() => {
vi.clearAllMocks();
useDataApiNullCallIndex = 0;
- dashboardSuggestionsEnabled = false;
- dashboardSuggestionsConfigFetched = false;
+ aiConfigState.dashboardSuggestionsEnabled = false;
+ aiConfigState.dashboardSuggestionsConfigFetched = false;
pendingDashboardSuggestionsFixture = [];
controlResultsFixture = [];
pendingDashboardSuggestionsReject = false;
@@ -312,7 +326,7 @@ describe('control implementations IndexView', () => {
});
it('matches pending suggestions to the selected control case-insensitively', async () => {
- dashboardSuggestionsEnabled = true;
+ aiConfigState.dashboardSuggestionsEnabled = true;
pendingDashboardSuggestionsFixture = [
{
id: 'suggestion-1',
@@ -351,8 +365,53 @@ describe('control implementations IndexView', () => {
);
});
+ it('loads dashboard suggestion state once when config resolution enables suggestions on mount', async () => {
+ aiConfigState.dashboardSuggestionsEnabled = true;
+ const configDeferred = createDeferred();
+ const pendingDeferred = createDeferred<{
+ data: { value: { data: unknown[] } };
+ }>();
+ const controlResultsDeferred = createDeferred<{
+ data: { value: { data: unknown[] } };
+ }>();
+
+ fetchDashboardSuggestionsConfig.mockImplementationOnce(async () => {
+ await configDeferred.promise;
+ aiConfigState.dashboardSuggestionsConfigFetched = true;
+ return aiConfigState.dashboardSuggestionsEnabled;
+ });
+ fetchPendingDashboardSuggestions.mockReturnValueOnce(
+ pendingDeferred.promise,
+ );
+ fetchDashboardSuggestionControlResults.mockReturnValueOnce(
+ controlResultsDeferred.promise,
+ );
+
+ mount(IndexView, { global: { stubs } });
+ configDeferred.resolve();
+ await flushPromises();
+
+ expect(fetchPendingDashboardSuggestions).toHaveBeenCalledTimes(1);
+ expect(fetchPendingDashboardSuggestions).toHaveBeenCalledWith(
+ expect.stringContaining(
+ '/api/oscal/system-security-plans/ssp-1/dashboard-suggestions?status=pending',
+ ),
+ expect.any(Object),
+ );
+ expect(fetchDashboardSuggestionControlResults).toHaveBeenCalledTimes(1);
+ expect(fetchDashboardSuggestionControlResults).toHaveBeenCalledWith(
+ expect.stringContaining(
+ '/api/oscal/system-security-plans/ssp-1/dashboard-suggestions/control-results',
+ ),
+ );
+
+ pendingDeferred.resolve({ data: { value: { data: [] } } });
+ controlResultsDeferred.resolve({ data: { value: { data: [] } } });
+ await waitForMountedControls();
+ });
+
it('keeps pending suggestions visible when control-results cannot be fetched', async () => {
- dashboardSuggestionsEnabled = true;
+ aiConfigState.dashboardSuggestionsEnabled = true;
controlResultsReject = true;
pendingDashboardSuggestionsFixture = [
{
@@ -377,7 +436,7 @@ describe('control implementations IndexView', () => {
});
it('keeps no-match state visible when pending suggestions cannot be fetched', async () => {
- dashboardSuggestionsEnabled = true;
+ aiConfigState.dashboardSuggestionsEnabled = true;
pendingDashboardSuggestionsReject = true;
controlResultsFixture = [
{
From a58cdc26970ac8961277cd55c6859496d665aed1 Mon Sep 17 00:00:00 2001
From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com>
Date: Thu, 18 Jun 2026 10:46:34 -0300
Subject: [PATCH 3/6] self-review: address pass 2 findings
---
src/composables/__tests__/axios.spec.ts | 61 +++++++
src/composables/useDashboardSuggestions.ts | 18 +--
.../control-implementations/IndexView.vue | 59 ++++---
.../__tests__/IndexView.spec.ts | 150 +++++++++++++++---
4 files changed, 230 insertions(+), 58 deletions(-)
create mode 100644 src/composables/__tests__/axios.spec.ts
diff --git a/src/composables/__tests__/axios.spec.ts b/src/composables/__tests__/axios.spec.ts
new file mode 100644
index 00000000..2c69b2a8
--- /dev/null
+++ b/src/composables/__tests__/axios.spec.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it, vi } from 'vitest';
+import { useAuthenticatedInstance } from '@/composables/axios';
+
+vi.mock('@/stores/config.ts', () => ({
+ useConfigStore: () => ({
+ getConfig: vi.fn(async () => ({ API_URL: 'http://api.test' })),
+ }),
+}));
+
+vi.mock('@/stores/auth', () => ({
+ useUserStore: () => ({
+ logout: vi.fn(),
+ }),
+}));
+
+vi.mock('vue-router', () => ({
+ useRouter: () => ({
+ push: vi.fn(),
+ }),
+}));
+
+vi.mock('primevue/usetoast', () => ({
+ useToast: () => ({
+ add: vi.fn(),
+ }),
+}));
+
+describe('axios response conversion', () => {
+ it('preserves dashboard suggestion label-map keys inside DataResponse arrays', async () => {
+ const instance = useAuthenticatedInstance();
+
+ const response = await instance.get('/dashboard-suggestions', {
+ camelcaseStopPaths: ['data.proposed_filter_label_set'],
+ adapter: async (config) => ({
+ data: {
+ data: [
+ {
+ id: 'suggestion-1',
+ proposed_filter_label_set: {
+ _policy: 'x',
+ service_name: 'api',
+ },
+ },
+ ],
+ },
+ status: 200,
+ statusText: 'OK',
+ headers: {},
+ config,
+ }),
+ });
+
+ expect(response.data.data[0]).toEqual({
+ id: 'suggestion-1',
+ proposedFilterLabelSet: {
+ _policy: 'x',
+ service_name: 'api',
+ },
+ });
+ });
+});
diff --git a/src/composables/useDashboardSuggestions.ts b/src/composables/useDashboardSuggestions.ts
index bd7b8cd2..da1dc990 100644
--- a/src/composables/useDashboardSuggestions.ts
+++ b/src/composables/useDashboardSuggestions.ts
@@ -96,9 +96,9 @@ export function useDashboardSuggestions(
// changes.
{
camelcaseStopPaths: [
- 'data.labelSet',
- 'data.proposedFilterLabelSet',
- 'data.originalProposedFilterLabelSet',
+ 'data.label_set',
+ 'data.proposed_filter_label_set',
+ 'data.original_proposed_filter_label_set',
],
},
);
@@ -195,9 +195,9 @@ export function useDashboardSuggestions(
buildDashboardSuggestionsEndpoint(sspId.value, status),
{
camelcaseStopPaths: [
- 'data.labelSet',
- 'data.proposedFilterLabelSet',
- 'data.originalProposedFilterLabelSet',
+ 'data.label_set',
+ 'data.proposed_filter_label_set',
+ 'data.original_proposed_filter_label_set',
],
},
);
@@ -236,9 +236,9 @@ export function useDashboardSuggestions(
transformRequest: [decamelizeKeys],
// Preserve raw label keys (e.g. `_policy`) the user kept in the filter.
camelcaseStopPaths: [
- 'data.labelSet',
- 'data.proposedFilterLabelSet',
- 'data.originalProposedFilterLabelSet',
+ 'data.label_set',
+ 'data.proposed_filter_label_set',
+ 'data.original_proposed_filter_label_set',
],
},
);
diff --git a/src/views/control-implementations/IndexView.vue b/src/views/control-implementations/IndexView.vue
index 8f51b7a3..59f26546 100644
--- a/src/views/control-implementations/IndexView.vue
+++ b/src/views/control-implementations/IndexView.vue
@@ -290,21 +290,15 @@ const { data: sspRisks, execute: loadSspRisks } = useDataApi(
{},
{ immediate: false },
);
-const {
- data: pendingDashboardSuggestions,
- execute: fetchPendingDashboardSuggestions,
- isLoading: pendingDashboardSuggestionsLoading,
-} = useDataApi(null, {}, { immediate: false });
-const {
- data: dashboardSuggestionControlResults,
- execute: fetchDashboardSuggestionControlResults,
- isLoading: dashboardSuggestionControlResultsLoading,
-} = useDataApi(null, {}, { immediate: false });
+const pendingDashboardSuggestions = ref([]);
+const dashboardSuggestionControlResults = ref([]);
+const dashboardSuggestionStateLoading = ref(false);
const nodes = ref>([]);
const loadedDashboardSuggestionStateFor = ref(null);
const loadingDashboardSuggestionStateFor = ref(null);
let dashboardSuggestionStateLoadPromise: Promise | null = null;
+const dashboardSuggestionStateRequestId = ref(0);
interface StatementSuggestionWorkItem {
requirement: ImplementedRequirement;
@@ -369,12 +363,6 @@ const selectedControlSuggestionResult = computed(() => {
return key ? dashboardSuggestionResultsByControl.value[key] : undefined;
});
-const dashboardSuggestionStateLoading = computed(
- () =>
- pendingDashboardSuggestionsLoading.value ||
- dashboardSuggestionControlResultsLoading.value,
-);
-
function normalizeId(value?: string): string {
return (value || '').trim().toLowerCase();
}
@@ -457,6 +445,8 @@ async function loadDashboardSuggestionState(force = false) {
loadedDashboardSuggestionStateFor.value = null;
loadingDashboardSuggestionStateFor.value = null;
dashboardSuggestionStateLoadPromise = null;
+ dashboardSuggestionStateRequestId.value += 1;
+ dashboardSuggestionStateLoading.value = false;
return;
}
@@ -474,30 +464,38 @@ async function loadDashboardSuggestionState(force = false) {
}
loadingDashboardSuggestionStateFor.value = sspId;
+ dashboardSuggestionStateLoading.value = true;
+ const requestId = (dashboardSuggestionStateRequestId.value += 1);
const loadPromise = (async () => {
const [pendingResult, controlResultsResult] = await Promise.allSettled([
- fetchPendingDashboardSuggestions(
+ axios.get>(
buildDashboardSuggestionsEndpoint(sspId, 'pending'),
{
camelcaseStopPaths: [
- 'data.labelSet',
- 'data.proposedFilterLabelSet',
- 'data.originalProposedFilterLabelSet',
+ 'data.label_set',
+ 'data.proposed_filter_label_set',
+ 'data.original_proposed_filter_label_set',
],
},
),
- fetchDashboardSuggestionControlResults(
+ axios.get>(
buildDashboardSuggestionControlResultsEndpoint(sspId),
),
]);
- if (pendingResult.status === 'rejected') {
- pendingDashboardSuggestions.value = [];
- }
- if (controlResultsResult.status === 'rejected') {
- dashboardSuggestionControlResults.value = [];
+ if (
+ dashboardSuggestionStateRequestId.value !== requestId ||
+ systemStore.system.securityPlan?.uuid !== sspId
+ ) {
+ return;
}
+ pendingDashboardSuggestions.value =
+ pendingResult.status === 'fulfilled' ? pendingResult.value.data.data : [];
+ dashboardSuggestionControlResults.value =
+ controlResultsResult.status === 'fulfilled'
+ ? controlResultsResult.value.data.data
+ : [];
loadedDashboardSuggestionStateFor.value = sspId;
})();
dashboardSuggestionStateLoadPromise = loadPromise;
@@ -511,6 +509,7 @@ async function loadDashboardSuggestionState(force = false) {
) {
loadingDashboardSuggestionStateFor.value = null;
dashboardSuggestionStateLoadPromise = null;
+ dashboardSuggestionStateLoading.value = false;
}
}
}
@@ -980,6 +979,10 @@ watch(
pendingDashboardSuggestions.value = [];
dashboardSuggestionControlResults.value = [];
loadedDashboardSuggestionStateFor.value = null;
+ loadingDashboardSuggestionStateFor.value = null;
+ dashboardSuggestionStateLoadPromise = null;
+ dashboardSuggestionStateRequestId.value += 1;
+ dashboardSuggestionStateLoading.value = false;
return;
}
@@ -1011,6 +1014,10 @@ watch(
pendingDashboardSuggestions.value = [];
dashboardSuggestionControlResults.value = [];
loadedDashboardSuggestionStateFor.value = null;
+ loadingDashboardSuggestionStateFor.value = null;
+ dashboardSuggestionStateLoadPromise = null;
+ dashboardSuggestionStateRequestId.value += 1;
+ dashboardSuggestionStateLoading.value = false;
return;
}
void loadDashboardSuggestionState();
diff --git a/src/views/control-implementations/__tests__/IndexView.spec.ts b/src/views/control-implementations/__tests__/IndexView.spec.ts
index 1a723fcf..3adc830e 100644
--- a/src/views/control-implementations/__tests__/IndexView.spec.ts
+++ b/src/views/control-implementations/__tests__/IndexView.spec.ts
@@ -15,6 +15,9 @@ const aiConfigState = reactive({
dashboardSuggestionsEnabled: false,
dashboardSuggestionsConfigFetched: false,
});
+const systemStoreState = reactive({
+ system: { securityPlan: { uuid: 'ssp-1' } as { uuid: string } | null },
+});
const fetchDashboardSuggestionsConfig = vi.fn(async () => {
aiConfigState.dashboardSuggestionsConfigFetched = true;
return aiConfigState.dashboardSuggestionsEnabled;
@@ -42,9 +45,7 @@ const uiStore = {
};
vi.mock('@/stores/system.ts', () => ({
- useSystemStore: () => ({
- system: { securityPlan: { uuid: 'ssp-1' } },
- }),
+ useSystemStore: () => systemStoreState,
}));
vi.mock('@/stores/system-security-plans', () => ({
@@ -225,6 +226,7 @@ describe('control implementations IndexView', () => {
useDataApiNullCallIndex = 0;
aiConfigState.dashboardSuggestionsEnabled = false;
aiConfigState.dashboardSuggestionsConfigFetched = false;
+ systemStoreState.system.securityPlan = { uuid: 'ssp-1' };
pendingDashboardSuggestionsFixture = [];
controlResultsFixture = [];
pendingDashboardSuggestionsReject = false;
@@ -235,7 +237,21 @@ describe('control implementations IndexView', () => {
listProfiles.mockResolvedValue({
data: [{ uuid: 'profile-1', title: 'Profile One' }],
});
- axiosGet.mockResolvedValue({ data: { data: { uuid: 'catalog-1' } } });
+ axiosGet.mockImplementation(async (url: string) => {
+ if (url.includes('/dashboard-suggestions?status=pending')) {
+ if (pendingDashboardSuggestionsReject) {
+ throw new Error('pending failed');
+ }
+ return { data: { data: pendingDashboardSuggestionsFixture } };
+ }
+ if (url.includes('/dashboard-suggestions/control-results')) {
+ if (controlResultsReject) {
+ throw new Error('results failed');
+ }
+ return { data: { data: controlResultsFixture } };
+ }
+ return { data: { data: { uuid: 'catalog-1' } } };
+ });
fetchControlImplementations.mockResolvedValue({
data: {
value: {
@@ -321,8 +337,13 @@ describe('control implementations IndexView', () => {
await flushPromises();
expect(wrapper.text()).not.toContain('AI dashboard suggestions');
- expect(fetchPendingDashboardSuggestions).not.toHaveBeenCalled();
- expect(fetchDashboardSuggestionControlResults).not.toHaveBeenCalled();
+ expect(axiosGet).not.toHaveBeenCalledWith(
+ expect.stringContaining('/dashboard-suggestions?status=pending'),
+ expect.anything(),
+ );
+ expect(axiosGet).not.toHaveBeenCalledWith(
+ expect.stringContaining('/dashboard-suggestions/control-results'),
+ );
});
it('matches pending suggestions to the selected control case-insensitively', async () => {
@@ -352,15 +373,15 @@ describe('control implementations IndexView', () => {
expect(wrapper.text()).toContain('AI dashboard suggestions');
expect(wrapper.text()).toContain('Production evidence');
expect(wrapper.text()).toContain('80% confidence');
- expect(fetchPendingDashboardSuggestions).toHaveBeenCalledWith(
+ expect(axiosGet).toHaveBeenCalledWith(
expect.stringContaining('/dashboard-suggestions?status=pending'),
expect.objectContaining({
camelcaseStopPaths: expect.arrayContaining([
- 'data.proposedFilterLabelSet',
+ 'data.proposed_filter_label_set',
]),
}),
);
- expect(fetchDashboardSuggestionControlResults).toHaveBeenCalledWith(
+ expect(axiosGet).toHaveBeenCalledWith(
expect.stringContaining('/dashboard-suggestions/control-results'),
);
});
@@ -369,10 +390,10 @@ describe('control implementations IndexView', () => {
aiConfigState.dashboardSuggestionsEnabled = true;
const configDeferred = createDeferred();
const pendingDeferred = createDeferred<{
- data: { value: { data: unknown[] } };
+ data: { data: unknown[] };
}>();
const controlResultsDeferred = createDeferred<{
- data: { value: { data: unknown[] } };
+ data: { data: unknown[] };
}>();
fetchDashboardSuggestionsConfig.mockImplementationOnce(async () => {
@@ -380,34 +401,117 @@ describe('control implementations IndexView', () => {
aiConfigState.dashboardSuggestionsConfigFetched = true;
return aiConfigState.dashboardSuggestionsEnabled;
});
- fetchPendingDashboardSuggestions.mockReturnValueOnce(
- pendingDeferred.promise,
- );
- fetchDashboardSuggestionControlResults.mockReturnValueOnce(
- controlResultsDeferred.promise,
- );
+ axiosGet.mockImplementation((url: string) => {
+ if (url.includes('/dashboard-suggestions?status=pending')) {
+ return pendingDeferred.promise;
+ }
+ if (url.includes('/dashboard-suggestions/control-results')) {
+ return controlResultsDeferred.promise;
+ }
+ return Promise.resolve({ data: { data: { uuid: 'catalog-1' } } });
+ });
mount(IndexView, { global: { stubs } });
configDeferred.resolve();
await flushPromises();
- expect(fetchPendingDashboardSuggestions).toHaveBeenCalledTimes(1);
- expect(fetchPendingDashboardSuggestions).toHaveBeenCalledWith(
+ const pendingCalls = axiosGet.mock.calls.filter(([url]) =>
+ String(url).includes('/dashboard-suggestions?status=pending'),
+ );
+ const controlResultsCalls = axiosGet.mock.calls.filter(([url]) =>
+ String(url).includes('/dashboard-suggestions/control-results'),
+ );
+ expect(pendingCalls).toHaveLength(1);
+ expect(axiosGet).toHaveBeenCalledWith(
expect.stringContaining(
'/api/oscal/system-security-plans/ssp-1/dashboard-suggestions?status=pending',
),
expect.any(Object),
);
- expect(fetchDashboardSuggestionControlResults).toHaveBeenCalledTimes(1);
- expect(fetchDashboardSuggestionControlResults).toHaveBeenCalledWith(
+ expect(controlResultsCalls).toHaveLength(1);
+ expect(axiosGet).toHaveBeenCalledWith(
expect.stringContaining(
'/api/oscal/system-security-plans/ssp-1/dashboard-suggestions/control-results',
),
);
- pendingDeferred.resolve({ data: { value: { data: [] } } });
- controlResultsDeferred.resolve({ data: { value: { data: [] } } });
+ pendingDeferred.resolve({ data: { data: [] } });
+ controlResultsDeferred.resolve({ data: { data: [] } });
+ await waitForMountedControls();
+ });
+
+ it('ignores stale dashboard suggestion responses after switching SSPs', async () => {
+ aiConfigState.dashboardSuggestionsEnabled = true;
+ aiConfigState.dashboardSuggestionsConfigFetched = true;
+ const ssp1Pending = createDeferred<{ data: { data: unknown[] } }>();
+ const ssp1ControlResults = createDeferred<{ data: { data: unknown[] } }>();
+ const ssp2Pending = createDeferred<{ data: { data: unknown[] } }>();
+ const ssp2ControlResults = createDeferred<{ data: { data: unknown[] } }>();
+
+ axiosGet.mockImplementation((url: string) => {
+ if (url.includes('/ssp-1/dashboard-suggestions?status=pending')) {
+ return ssp1Pending.promise;
+ }
+ if (url.includes('/ssp-1/dashboard-suggestions/control-results')) {
+ return ssp1ControlResults.promise;
+ }
+ if (url.includes('/ssp-2/dashboard-suggestions?status=pending')) {
+ return ssp2Pending.promise;
+ }
+ if (url.includes('/ssp-2/dashboard-suggestions/control-results')) {
+ return ssp2ControlResults.promise;
+ }
+ return Promise.resolve({ data: { data: { uuid: 'catalog-1' } } });
+ });
+
+ const wrapper = mount(IndexView, { global: { stubs } });
+ await flushPromises();
+
+ systemStoreState.system.securityPlan = { uuid: 'ssp-2' };
+ await flushPromises();
+
+ ssp2Pending.resolve({
+ data: {
+ data: [
+ {
+ id: 'suggestion-2',
+ status: 'pending',
+ controlId: 'AC-1',
+ labelSetHash: 'hash-2',
+ proposedFilterName: 'Second SSP suggestion',
+ },
+ ],
+ },
+ });
+ ssp2ControlResults.resolve({ data: { data: [] } });
+ await flushPromises();
+
+ ssp1Pending.resolve({
+ data: {
+ data: [
+ {
+ id: 'suggestion-1',
+ status: 'pending',
+ controlId: 'AC-1',
+ labelSetHash: 'hash-1',
+ proposedFilterName: 'First SSP stale suggestion',
+ },
+ ],
+ },
+ });
+ ssp1ControlResults.resolve({ data: { data: [] } });
await waitForMountedControls();
+
+ const callsBeforeDrawerOpen = axiosGet.mock.calls.length;
+ const implementationButton = wrapper
+ .findAll('button')
+ .find((button) => button.attributes('title') === 'View implementation');
+ await implementationButton?.trigger('click');
+ await flushPromises();
+
+ expect(wrapper.text()).toContain('Second SSP suggestion');
+ expect(wrapper.text()).not.toContain('First SSP stale suggestion');
+ expect(axiosGet.mock.calls).toHaveLength(callsBeforeDrawerOpen);
});
it('keeps pending suggestions visible when control-results cannot be fetched', async () => {
From db357acafaa335d33caa7454d7d28e3547594c69 Mon Sep 17 00:00:00 2001
From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com>
Date: Thu, 18 Jun 2026 10:51:09 -0300
Subject: [PATCH 4/6] self-review: address pass 3 findings
---
.../control-implementations/IndexView.vue | 14 ++++++++++++--
.../__tests__/IndexView.spec.ts | 18 ++++++++++++++++++
2 files changed, 30 insertions(+), 2 deletions(-)
diff --git a/src/views/control-implementations/IndexView.vue b/src/views/control-implementations/IndexView.vue
index 59f26546..7c979c8a 100644
--- a/src/views/control-implementations/IndexView.vue
+++ b/src/views/control-implementations/IndexView.vue
@@ -514,6 +514,17 @@ async function loadDashboardSuggestionState(force = false) {
}
}
+async function initializeDashboardSuggestionState() {
+ try {
+ await aiConfigStore.fetchDashboardSuggestionsConfig();
+ if (aiConfigStore.dashboardSuggestionsEnabled) {
+ await loadDashboardSuggestionState();
+ }
+ } catch {
+ // Dashboard suggestions are optional; do not block the core controls view.
+ }
+}
+
function openControlRisks(controlId?: string) {
if (!controlId) {
return;
@@ -1035,8 +1046,7 @@ watch(
);
onMounted(async () => {
- await aiConfigStore.fetchDashboardSuggestionsConfig();
- await loadDashboardSuggestionState();
+ void initializeDashboardSuggestionState();
try {
await loadProfileBindings();
diff --git a/src/views/control-implementations/__tests__/IndexView.spec.ts b/src/views/control-implementations/__tests__/IndexView.spec.ts
index 3adc830e..7a31f94b 100644
--- a/src/views/control-implementations/__tests__/IndexView.spec.ts
+++ b/src/views/control-implementations/__tests__/IndexView.spec.ts
@@ -346,6 +346,24 @@ describe('control implementations IndexView', () => {
);
});
+ it('loads core controls data before dashboard suggestions config resolves', async () => {
+ const configDeferred = createDeferred();
+ fetchDashboardSuggestionsConfig.mockImplementationOnce(
+ () => configDeferred.promise,
+ );
+
+ mount(IndexView, { global: { stubs } });
+ await waitForMountedControls();
+
+ expect(fetchDashboardSuggestionsConfig).toHaveBeenCalled();
+ expect(listProfiles).toHaveBeenCalled();
+ expect(fetchControlImplementations).toHaveBeenCalled();
+ expect(axiosGet).not.toHaveBeenCalledWith(
+ expect.stringContaining('/dashboard-suggestions?status=pending'),
+ expect.anything(),
+ );
+ });
+
it('matches pending suggestions to the selected control case-insensitively', async () => {
aiConfigState.dashboardSuggestionsEnabled = true;
pendingDashboardSuggestionsFixture = [
From 57b2547632e4c99ed925fee11e5661fcf2d3d8ef Mon Sep 17 00:00:00 2001
From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com>
Date: Thu, 18 Jun 2026 11:15:31 -0300
Subject: [PATCH 5/6] fix: address review feedback
---
.../control-implementations/IndexView.vue | 5 +-
.../__tests__/IndexView.spec.ts | 64 ++-----------------
2 files changed, 8 insertions(+), 61 deletions(-)
diff --git a/src/views/control-implementations/IndexView.vue b/src/views/control-implementations/IndexView.vue
index 7c979c8a..7e14319a 100644
--- a/src/views/control-implementations/IndexView.vue
+++ b/src/views/control-implementations/IndexView.vue
@@ -437,7 +437,7 @@ function controlHighestSeverity(
return stats.highestSeverity ?? 'high';
}
-async function loadDashboardSuggestionState(force = false) {
+async function loadDashboardSuggestionState() {
const sspId = systemStore.system.securityPlan?.uuid;
if (!sspId || !aiConfigStore.dashboardSuggestionsEnabled) {
pendingDashboardSuggestions.value = [];
@@ -450,12 +450,11 @@ async function loadDashboardSuggestionState(force = false) {
return;
}
- if (!force && loadedDashboardSuggestionStateFor.value === sspId) {
+ if (loadedDashboardSuggestionStateFor.value === sspId) {
return;
}
if (
- !force &&
loadingDashboardSuggestionStateFor.value === sspId &&
dashboardSuggestionStateLoadPromise
) {
diff --git a/src/views/control-implementations/__tests__/IndexView.spec.ts b/src/views/control-implementations/__tests__/IndexView.spec.ts
index 7a31f94b..1c72341e 100644
--- a/src/views/control-implementations/__tests__/IndexView.spec.ts
+++ b/src/views/control-implementations/__tests__/IndexView.spec.ts
@@ -9,8 +9,6 @@ const listProfiles = vi.fn();
const axiosGet = vi.fn();
const loadRisks = vi.fn(async () => ({ data: { value: { data: [] } } }));
const fetchControlImplementations = vi.fn();
-const fetchPendingDashboardSuggestions = vi.fn();
-const fetchDashboardSuggestionControlResults = vi.fn();
const aiConfigState = reactive({
dashboardSuggestionsEnabled: false,
dashboardSuggestionsConfigFetched: false,
@@ -26,7 +24,6 @@ let pendingDashboardSuggestionsFixture: unknown[] = [];
let controlResultsFixture: unknown[] = [];
let pendingDashboardSuggestionsReject = false;
let controlResultsReject = false;
-let useDataApiNullCallIndex = 0;
const uiStore = {
controlImplementationDrawerOpen: false,
controlImplementationSelectedRequirementId: null as string | null,
@@ -105,36 +102,12 @@ vi.mock('@/composables/axios', () => ({
};
}
if (url === null) {
- const callIndex = useDataApiNullCallIndex;
- useDataApiNullCallIndex += 1;
- if (callIndex % 3 === 1) {
- const data = ref([]);
- return {
- data,
- isLoading: ref(false),
- error: ref(null),
- execute: async (...args: unknown[]) => {
- const response = await fetchPendingDashboardSuggestions(...args);
- data.value = response.data.value.data;
- return response;
- },
- };
- }
- if (callIndex % 3 === 2) {
- const data = ref([]);
- return {
- data,
- isLoading: ref(false),
- error: ref(null),
- execute: async (...args: unknown[]) => {
- const response = await fetchDashboardSuggestionControlResults(
- ...args,
- );
- data.value = response.data.value.data;
- return response;
- },
- };
- }
+ return {
+ data: ref([]),
+ isLoading: ref(false),
+ error: ref(null),
+ execute: loadRisks,
+ };
}
return {
data: ref([]),
@@ -223,7 +196,6 @@ const stubs = {
describe('control implementations IndexView', () => {
beforeEach(() => {
vi.clearAllMocks();
- useDataApiNullCallIndex = 0;
aiConfigState.dashboardSuggestionsEnabled = false;
aiConfigState.dashboardSuggestionsConfigFetched = false;
systemStoreState.system.securityPlan = { uuid: 'ssp-1' };
@@ -267,30 +239,6 @@ describe('control implementations IndexView', () => {
},
},
});
- fetchPendingDashboardSuggestions.mockImplementation(async () => {
- if (pendingDashboardSuggestionsReject) {
- throw new Error('pending failed');
- }
- return {
- data: {
- value: {
- data: pendingDashboardSuggestionsFixture,
- },
- },
- };
- });
- fetchDashboardSuggestionControlResults.mockImplementation(async () => {
- if (controlResultsReject) {
- throw new Error('results failed');
- }
- return {
- data: {
- value: {
- data: controlResultsFixture,
- },
- },
- };
- });
});
it('disables the implementation eye button when a control has no implementation', async () => {
From c8aed5a6e0f025d5f06c4616f57bbe7691df2c43 Mon Sep 17 00:00:00 2001
From: Gustavo Carvalho
Date: Thu, 18 Jun 2026 11:46:27 -0300
Subject: [PATCH 6/6] fix: dashboard fixes
Signed-off-by: Gustavo Carvalho
---
src/composables/__tests__/axios.spec.ts | 6 +-
src/composables/useDashboardSuggestions.ts | 21 ++----
.../control-implementations/IndexView.vue | 64 +++++++++++++------
.../__tests__/IndexView.spec.ts | 52 ++++++++++++++-
.../partials/RiskIndicatorBadge.vue | 8 ++-
.../partials/SuggestionIndicatorBadge.vue | 39 +++++++++++
.../__tests__/RiskIndicatorBadge.spec.ts | 50 +++++++++++++++
.../partials/dashboard-suggestions.ts | 12 ++++
8 files changed, 211 insertions(+), 41 deletions(-)
create mode 100644 src/views/control-implementations/partials/SuggestionIndicatorBadge.vue
create mode 100644 src/views/control-implementations/partials/__tests__/RiskIndicatorBadge.spec.ts
diff --git a/src/composables/__tests__/axios.spec.ts b/src/composables/__tests__/axios.spec.ts
index 2c69b2a8..8c072918 100644
--- a/src/composables/__tests__/axios.spec.ts
+++ b/src/composables/__tests__/axios.spec.ts
@@ -30,13 +30,15 @@ describe('axios response conversion', () => {
const instance = useAuthenticatedInstance();
const response = await instance.get('/dashboard-suggestions', {
- camelcaseStopPaths: ['data.proposed_filter_label_set'],
+ // This endpoint returns camelCase field names; camelcase-keys matches
+ // stopPaths against the original keys, so the stop path is camelCase too.
+ camelcaseStopPaths: ['data.proposedFilterLabelSet'],
adapter: async (config) => ({
data: {
data: [
{
id: 'suggestion-1',
- proposed_filter_label_set: {
+ proposedFilterLabelSet: {
_policy: 'x',
service_name: 'api',
},
diff --git a/src/composables/useDashboardSuggestions.ts b/src/composables/useDashboardSuggestions.ts
index da1dc990..a5f84d82 100644
--- a/src/composables/useDashboardSuggestions.ts
+++ b/src/composables/useDashboardSuggestions.ts
@@ -22,6 +22,7 @@ import {
buildGeneralizeDashboardSuggestionsEndpoint,
buildLatestDashboardSuggestionRunEndpoint,
buildRejectDashboardSuggestionsEndpoint,
+ DASHBOARD_SUGGESTION_LABEL_STOP_PATHS,
type DashboardSuggestion,
type DashboardSuggestionEvent,
type DashboardSuggestionLabelKey,
@@ -93,13 +94,9 @@ export function useDashboardSuggestions(
// Preserve raw label keys (e.g. `_policy`) on the originating label set,
// the proposed filter, and the AI baseline used for the edit diff;
// otherwise camelcase conversion strips `_` and the diff shows phantom
- // changes.
+ // changes. See DASHBOARD_SUGGESTION_LABEL_STOP_PATHS for why these are camelCase.
{
- camelcaseStopPaths: [
- 'data.label_set',
- 'data.proposed_filter_label_set',
- 'data.original_proposed_filter_label_set',
- ],
+ camelcaseStopPaths: DASHBOARD_SUGGESTION_LABEL_STOP_PATHS,
},
);
}
@@ -194,11 +191,7 @@ export function useDashboardSuggestions(
const response = await fetchHistoryRequest(
buildDashboardSuggestionsEndpoint(sspId.value, status),
{
- camelcaseStopPaths: [
- 'data.label_set',
- 'data.proposed_filter_label_set',
- 'data.original_proposed_filter_label_set',
- ],
+ camelcaseStopPaths: DASHBOARD_SUGGESTION_LABEL_STOP_PATHS,
},
);
collected.push(...(response?.data.value?.data ?? []));
@@ -235,11 +228,7 @@ export function useDashboardSuggestions(
data: payload,
transformRequest: [decamelizeKeys],
// Preserve raw label keys (e.g. `_policy`) the user kept in the filter.
- camelcaseStopPaths: [
- 'data.label_set',
- 'data.proposed_filter_label_set',
- 'data.original_proposed_filter_label_set',
- ],
+ camelcaseStopPaths: DASHBOARD_SUGGESTION_LABEL_STOP_PATHS,
},
);
await refreshPendingSuggestions();
diff --git a/src/views/control-implementations/IndexView.vue b/src/views/control-implementations/IndexView.vue
index 7e14319a..5e4da46c 100644
--- a/src/views/control-implementations/IndexView.vue
+++ b/src/views/control-implementations/IndexView.vue
@@ -103,11 +103,7 @@
? 'View implementation'
: 'No implementation yet'
"
- @click="
- openImplementationDrawer(
- controlImplementations[slotProps.node.data.id],
- )
- "
+ @click="openControlDrawer(slotProps.node.data.id)"
>
+
@@ -143,7 +144,7 @@
>
(
{},
);
const selectedImplementedRequirement = ref();
+// The control whose drawer is open. Tracked separately from the implemented
+// requirement so the drawer (and its AI suggestions panel) can open for a
+// control that has pending suggestions but no implementation yet.
+const selectedControlId = ref();
const RISK_FETCH_LIMIT = 100;
const loadedSspRisksFor = ref(null);
const preparingBulkSuggestions = ref(false);
@@ -353,16 +360,28 @@ const dashboardSuggestionResultsByControl = computed(() => {
return results;
});
+const selectedDrawerControlId = computed(
+ () =>
+ selectedControlId.value ?? selectedImplementedRequirement.value?.controlId,
+);
+
const selectedControlDashboardSuggestions = computed(() => {
- const key = normalizeId(selectedImplementedRequirement.value?.controlId);
+ const key = normalizeId(selectedDrawerControlId.value);
return key ? (pendingDashboardSuggestionsByControl.value[key] ?? []) : [];
});
const selectedControlSuggestionResult = computed(() => {
- const key = normalizeId(selectedImplementedRequirement.value?.controlId);
+ const key = normalizeId(selectedDrawerControlId.value);
return key ? dashboardSuggestionResultsByControl.value[key] : undefined;
});
+function controlSuggestionCount(controlId?: string): number {
+ const key = normalizeId(controlId);
+ return key
+ ? (pendingDashboardSuggestionsByControl.value[key]?.length ?? 0)
+ : 0;
+}
+
function normalizeId(value?: string): string {
return (value || '').trim().toLowerCase();
}
@@ -470,11 +489,7 @@ async function loadDashboardSuggestionState() {
axios.get>(
buildDashboardSuggestionsEndpoint(sspId, 'pending'),
{
- camelcaseStopPaths: [
- 'data.label_set',
- 'data.proposed_filter_label_set',
- 'data.original_proposed_filter_label_set',
- ],
+ camelcaseStopPaths: DASHBOARD_SUGGESTION_LABEL_STOP_PATHS,
},
),
axios.get>(
@@ -590,6 +605,7 @@ async function loadControlImplementations() {
uiStore.controlImplementationDrawerOpen
) {
selectedImplementedRequirement.value = impl;
+ selectedControlId.value = impl.controlId;
selectedRequirementFound = true;
}
}
@@ -970,13 +986,20 @@ function hasControlImplementation(controlId?: string): boolean {
return !!(controlId && controlImplementations.value[controlId]);
}
-function openImplementationDrawer(req: ImplementedRequirement | undefined) {
- if (!req) {
+// Opens the implementation drawer for a control. The control need not have an
+// implementation: when it only has pending AI suggestions, the drawer still
+// opens (with an empty Components section) so the suggestions panel is reachable.
+function openControlDrawer(controlId?: string) {
+ if (!controlId) {
return;
}
+ const requirement = controlImplementations.value[controlId];
uiStore.setControlImplementationDrawerOpen(true);
- uiStore.setControlImplementationSelectedRequirementId(req.uuid);
- selectedImplementedRequirement.value = req;
+ uiStore.setControlImplementationSelectedRequirementId(
+ requirement?.uuid ?? null,
+ );
+ selectedControlId.value = controlId;
+ selectedImplementedRequirement.value = requirement;
void loadDashboardSuggestionState();
}
@@ -1037,9 +1060,12 @@ watch(
watch(
() => uiStore.controlImplementationDrawerOpen,
(isOpen) => {
- if (!isOpen && uiStore.controlImplementationSelectedRequirementId) {
- uiStore.setControlImplementationSelectedRequirementId(null);
+ if (!isOpen) {
+ if (uiStore.controlImplementationSelectedRequirementId) {
+ uiStore.setControlImplementationSelectedRequirementId(null);
+ }
selectedImplementedRequirement.value = undefined;
+ selectedControlId.value = undefined;
}
},
);
diff --git a/src/views/control-implementations/__tests__/IndexView.spec.ts b/src/views/control-implementations/__tests__/IndexView.spec.ts
index 1c72341e..a66b5531 100644
--- a/src/views/control-implementations/__tests__/IndexView.spec.ts
+++ b/src/views/control-implementations/__tests__/IndexView.spec.ts
@@ -343,7 +343,7 @@ describe('control implementations IndexView', () => {
expect.stringContaining('/dashboard-suggestions?status=pending'),
expect.objectContaining({
camelcaseStopPaths: expect.arrayContaining([
- 'data.proposed_filter_label_set',
+ 'data.proposedFilterLabelSet',
]),
}),
);
@@ -529,4 +529,54 @@ describe('control implementations IndexView', () => {
'AI reviewed this control and found no matching dashboard filter',
);
});
+
+ it('surfaces a clickable suggestions badge that opens the drawer for a control with no implementation', async () => {
+ // ac-2 has no implemented requirement (only ac-1 does), so its eye button is
+ // disabled. A pending suggestion for ac-2 must still be reachable via a badge.
+ aiConfigState.dashboardSuggestionsEnabled = true;
+ pendingDashboardSuggestionsFixture = [
+ {
+ id: 'suggestion-2',
+ status: 'pending',
+ controlId: 'AC-2',
+ labelSetHash: 'hash-2',
+ proposedFilterName: 'Unimplemented control suggestion',
+ },
+ ];
+
+ const wrapper = mount(IndexView, { global: { stubs } });
+ await waitForMountedControls();
+
+ // The ac-2 eye button is disabled (no implementation to view).
+ const eyeButtons = wrapper
+ .findAll('button')
+ .filter((button) => button.attributes('title') !== undefined);
+ expect(
+ eyeButtons.some((button) => button.attributes('disabled') !== undefined),
+ ).toBe(true);
+
+ const badge = wrapper
+ .findAll('button')
+ .find((button) =>
+ button
+ .attributes('aria-label')
+ ?.includes('pending AI dashboard suggestion'),
+ );
+ expect(badge).toBeTruthy();
+ expect(badge?.text()).toContain('1');
+
+ // Before opening, the drawer's suggestions panel is not scoped to ac-2.
+ expect(wrapper.text()).not.toContain('Unimplemented control suggestion');
+
+ await badge?.trigger('click');
+ await flushPromises();
+
+ expect(uiStore.setControlImplementationDrawerOpen).toHaveBeenCalledWith(
+ true,
+ );
+ expect(
+ uiStore.setControlImplementationSelectedRequirementId,
+ ).toHaveBeenCalledWith(null);
+ expect(wrapper.text()).toContain('Unimplemented control suggestion');
+ });
});
diff --git a/src/views/control-implementations/partials/RiskIndicatorBadge.vue b/src/views/control-implementations/partials/RiskIndicatorBadge.vue
index f4c98896..b5782df4 100644
--- a/src/views/control-implementations/partials/RiskIndicatorBadge.vue
+++ b/src/views/control-implementations/partials/RiskIndicatorBadge.vue
@@ -7,8 +7,10 @@ const props = defineProps<{
highestSeverity?: 'high' | 'medium' | 'low';
clickable?: boolean;
}>();
+// Forward the native event so callers can use the `.stop` modifier without
+// `.stopPropagation()` being called on `undefined`.
const emit = defineEmits<{
- click: [];
+ click: [event: MouseEvent];
}>();
const displayCount = computed(() =>
@@ -36,11 +38,11 @@ const tooltipText = computed(() => {
return `${displayCount.value} risks associated`;
});
-function onClick() {
+function onClick(event: MouseEvent) {
if (!props.clickable) {
return;
}
- emit('click');
+ emit('click', event);
}
diff --git a/src/views/control-implementations/partials/SuggestionIndicatorBadge.vue b/src/views/control-implementations/partials/SuggestionIndicatorBadge.vue
new file mode 100644
index 00000000..8f9cdc0c
--- /dev/null
+++ b/src/views/control-implementations/partials/SuggestionIndicatorBadge.vue
@@ -0,0 +1,39 @@
+
+
+
+
+
diff --git a/src/views/control-implementations/partials/__tests__/RiskIndicatorBadge.spec.ts b/src/views/control-implementations/partials/__tests__/RiskIndicatorBadge.spec.ts
new file mode 100644
index 00000000..0c5e5ff8
--- /dev/null
+++ b/src/views/control-implementations/partials/__tests__/RiskIndicatorBadge.spec.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { defineComponent } from 'vue';
+import RiskIndicatorBadge from '../RiskIndicatorBadge.vue';
+
+describe('RiskIndicatorBadge', () => {
+ it('renders nothing when there are no risks', () => {
+ const wrapper = mount(RiskIndicatorBadge, {
+ props: { riskCount: 0 },
+ });
+ expect(wrapper.find('button').exists()).toBe(false);
+ expect(wrapper.find('span').exists()).toBe(false);
+ });
+
+ it('caps the displayed count at 99+ when capped', () => {
+ const wrapper = mount(RiskIndicatorBadge, {
+ props: { riskCount: 250, isCapped: true },
+ });
+ expect(wrapper.text()).toContain('99+');
+ });
+
+ it('does not emit when not clickable', async () => {
+ const wrapper = mount(RiskIndicatorBadge, {
+ props: { riskCount: 3 },
+ });
+ await wrapper.find('span').trigger('click');
+ expect(wrapper.emitted('click')).toBeUndefined();
+ });
+
+ // Regression: emitting `click` without the native event made a parent
+ // `@click.stop` handler call `.stopPropagation()` on `undefined` and throw.
+ it('can be clicked through a parent @click.stop handler without throwing', async () => {
+ let handled = false;
+ const Parent = defineComponent({
+ components: { RiskIndicatorBadge },
+ setup() {
+ return {
+ onClick: () => {
+ handled = true;
+ },
+ };
+ },
+ template: ``,
+ });
+
+ const wrapper = mount(Parent);
+ await wrapper.find('button').trigger('click');
+ expect(handled).toBe(true);
+ });
+});
diff --git a/src/views/dashboard/partials/dashboard-suggestions.ts b/src/views/dashboard/partials/dashboard-suggestions.ts
index 6f028b57..048c699f 100644
--- a/src/views/dashboard/partials/dashboard-suggestions.ts
+++ b/src/views/dashboard/partials/dashboard-suggestions.ts
@@ -376,6 +376,18 @@ export function buildControlKey(catalogId: string, controlId: string): string {
return `${catalogId}:${controlId}`;
}
+// Response paths whose nested objects are user-defined label maps (keys like
+// `_policy` or `service_name`). The dashboard-suggestion endpoints return these
+// field names already camelCased, and `camelcase-keys` matches stopPaths against
+// the response's own keys — so these must stay camelCase, and the leading `_`
+// and snake_case label keys inside the maps are preserved rather than mangled.
+// Defined once so the request sites that read suggestion label maps can't drift.
+export const DASHBOARD_SUGGESTION_LABEL_STOP_PATHS: readonly string[] = [
+ 'data.labelSet',
+ 'data.proposedFilterLabelSet',
+ 'data.originalProposedFilterLabelSet',
+];
+
export function formatLabelSet(labels: Record): string[] {
return Object.entries(labels).map(([key, value]) => `${key}=${value}`);
}