From b2c8393c2a1570b096d760e42df9f9b8dcb7260b Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:09:49 -0300 Subject: [PATCH 01/14] implement: bch-1312-suggestions-ui --- .../__tests__/useDashboardSuggestions.spec.ts | 78 +++ src/composables/useDashboardSuggestions.ts | 265 +++++++++ src/router/__tests__/index.spec.ts | 15 + src/router/index.ts | 8 + src/stores/ai-config.ts | 45 ++ src/stores/filters.ts | 1 + src/views/dashboard/CreateFormView.vue | 20 + src/views/dashboard/IndexView.vue | 205 +++++-- src/views/dashboard/SuggestionsView.vue | 542 ++++++++++++++++++ .../dashboard/__tests__/IndexView.spec.ts | 140 +++++ .../__tests__/SuggestionsView.spec.ts | 177 ++++++ .../partials/SuggestionScopeDialog.vue | 198 +++++++ .../__tests__/SuggestionScopeDialog.spec.ts | 106 ++++ .../partials/dashboard-suggestions.ts | 149 +++++ 14 files changed, 1909 insertions(+), 40 deletions(-) create mode 100644 src/composables/__tests__/useDashboardSuggestions.spec.ts create mode 100644 src/composables/useDashboardSuggestions.ts create mode 100644 src/stores/ai-config.ts create mode 100644 src/views/dashboard/SuggestionsView.vue create mode 100644 src/views/dashboard/__tests__/IndexView.spec.ts create mode 100644 src/views/dashboard/__tests__/SuggestionsView.spec.ts create mode 100644 src/views/dashboard/partials/SuggestionScopeDialog.vue create mode 100644 src/views/dashboard/partials/__tests__/SuggestionScopeDialog.spec.ts create mode 100644 src/views/dashboard/partials/dashboard-suggestions.ts diff --git a/src/composables/__tests__/useDashboardSuggestions.spec.ts b/src/composables/__tests__/useDashboardSuggestions.spec.ts new file mode 100644 index 00000000..7aa1130f --- /dev/null +++ b/src/composables/__tests__/useDashboardSuggestions.spec.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { effectScope, ref } from 'vue'; + +const mocks = vi.hoisted(() => ({ + execute: vi.fn(), + toastAdd: vi.fn(), +})); + +vi.mock('@/composables/axios', () => ({ + useDataApi: () => ({ + execute: mocks.execute, + isLoading: { value: false }, + error: { value: null }, + }), + decamelizeKeys: vi.fn((data: unknown) => JSON.stringify(data)), +})); + +vi.mock('primevue/usetoast', () => ({ + useToast: () => ({ add: mocks.toastAdd }), +})); + +import { useSuggestionRunPoller } from '../useDashboardSuggestions'; + +function response(status: string, completedCells: number, id = status) { + return { + data: { + value: { + data: { + id, + status, + plannedCalls: 2, + completedCells, + failedCells: 0, + }, + }, + }, + }; +} + +describe('useSuggestionRunPoller', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('starts polling active runs, stops on terminal status, and fires toast', async () => { + const onPoll = vi.fn(); + mocks.execute + .mockResolvedValueOnce(response('running', 1, 'run-1')) + .mockResolvedValueOnce(response('completed', 2, 'run-1')); + + const scope = effectScope(); + const poller = scope.run(() => + useSuggestionRunPoller(ref('ssp-1'), onPoll), + ); + + await poller?.pollLatest(); + expect(poller?.isPolling.value).toBe(true); + + await vi.advanceTimersByTimeAsync(3000); + + expect(mocks.execute).toHaveBeenCalledTimes(2); + expect(onPoll).toHaveBeenCalledTimes(2); + expect(poller?.isPolling.value).toBe(false); + expect(mocks.toastAdd).toHaveBeenCalledWith( + expect.objectContaining({ + severity: 'success', + summary: 'Suggestions ready', + }), + ); + + scope.stop(); + }); +}); diff --git a/src/composables/useDashboardSuggestions.ts b/src/composables/useDashboardSuggestions.ts new file mode 100644 index 00000000..db3637c9 --- /dev/null +++ b/src/composables/useDashboardSuggestions.ts @@ -0,0 +1,265 @@ +import { + computed, + onMounted, + onUnmounted, + ref, + shallowRef, + watch, + type Ref, +} from 'vue'; +import { useToast } from 'primevue/usetoast'; +import { decamelizeKeys, useDataApi } from '@/composables/axios'; +import { + buildAcceptDashboardSuggestionsEndpoint, + buildDashboardSuggestionEventsEndpoint, + buildDashboardSuggestionLabelSetsEndpoint, + buildDashboardSuggestionsEndpoint, + buildGenerateDashboardSuggestionsEndpoint, + buildLatestDashboardSuggestionRunEndpoint, + buildRejectDashboardSuggestionsEndpoint, + type DashboardSuggestion, + type DashboardSuggestionEvent, + type DashboardSuggestionLabelSet, + type GenerateDashboardSuggestionsPayload, + type SuggestionRun, + isRunActive, +} from '@/views/dashboard/partials/dashboard-suggestions'; + +export function useDashboardSuggestions(sspId: Ref) { + const pendingEndpoint = computed(() => + sspId.value + ? buildDashboardSuggestionsEndpoint(sspId.value, 'pending') + : '', + ); + const labelSetsEndpoint = computed(() => + sspId.value ? buildDashboardSuggestionLabelSetsEndpoint(sspId.value) : '', + ); + + const { + data: pendingSuggestions, + execute: refreshPendingSuggestions, + isLoading: pendingSuggestionsLoading, + } = useDataApi(pendingEndpoint); + const historySuggestions = shallowRef([]); + const { execute: fetchHistoryRequest, isLoading: historySuggestionsLoading } = + useDataApi(null, {}, { immediate: false }); + const { + data: labelSets, + execute: refreshLabelSets, + isLoading: labelSetsLoading, + } = useDataApi(labelSetsEndpoint); + + const { execute: generateRequest, isLoading: generating } = + useDataApi(null, {}, { immediate: false }); + const { execute: acceptRequest, isLoading: accepting } = useDataApi( + null, + {}, + { immediate: false }, + ); + const { execute: rejectRequest, isLoading: rejecting } = useDataApi( + null, + {}, + { immediate: false }, + ); + const { execute: eventsRequest, isLoading: loadingEvents } = useDataApi< + DashboardSuggestionEvent[] + >(null, {}, { immediate: false }); + + async function generateSuggestions( + payload: GenerateDashboardSuggestionsPayload, + ) { + return generateRequest( + buildGenerateDashboardSuggestionsEndpoint(sspId.value), + { + method: 'POST', + data: payload, + transformRequest: [decamelizeKeys], + }, + ); + } + + async function acceptSuggestions(ids: string[]) { + await acceptRequest(buildAcceptDashboardSuggestionsEndpoint(sspId.value), { + method: 'POST', + data: { ids }, + transformRequest: [decamelizeKeys], + }); + await refreshPendingSuggestions(); + await refreshHistorySuggestions(); + } + + async function refreshHistorySuggestions() { + if (!sspId.value) { + historySuggestions.value = []; + return; + } + + const statuses = ['accepted', 'rejected', 'superseded']; + const responses = await Promise.all( + statuses.map((status) => + fetchHistoryRequest( + buildDashboardSuggestionsEndpoint(sspId.value, status), + ), + ), + ); + historySuggestions.value = responses.flatMap( + (response) => response?.data.value?.data ?? [], + ); + } + + async function rejectSuggestions(ids: string[], reason?: string) { + await rejectRequest(buildRejectDashboardSuggestionsEndpoint(sspId.value), { + method: 'POST', + data: { ids, reason }, + transformRequest: [decamelizeKeys], + }); + await refreshPendingSuggestions(); + await refreshHistorySuggestions(); + } + + async function fetchSuggestionEvents(suggestionId: string) { + const response = await eventsRequest( + buildDashboardSuggestionEventsEndpoint(sspId.value, suggestionId), + ); + return response?.data.value?.data ?? []; + } + + onMounted(() => { + void refreshHistorySuggestions(); + }); + + return { + pendingSuggestions, + historySuggestions, + labelSets, + pendingSuggestionsLoading, + historySuggestionsLoading, + labelSetsLoading, + generating, + accepting, + rejecting, + loadingEvents, + refreshPendingSuggestions, + refreshHistorySuggestions, + refreshLabelSets, + generateSuggestions, + acceptSuggestions, + rejectSuggestions, + fetchSuggestionEvents, + }; +} + +export function useSuggestionRunPoller( + sspId: Ref, + onPoll?: () => Promise | void, +) { + const toast = useToast(); + const run = ref(); + const timer = ref(); + const terminalToastShownFor = ref(); + + const { execute, isLoading, error } = useDataApi( + null, + {}, + { immediate: false }, + ); + + const progressPercent = computed(() => { + if (!run.value?.plannedCalls) { + return 0; + } + return Math.min( + 100, + Math.round((run.value.completedCells / run.value.plannedCalls) * 100), + ); + }); + + function stop() { + if (timer.value) { + window.clearInterval(timer.value); + timer.value = undefined; + } + } + + async function pollLatest() { + if (!sspId.value) { + return; + } + + try { + const response = await execute( + buildLatestDashboardSuggestionRunEndpoint(sspId.value), + ); + const latestRun = response?.data.value?.data; + if (!latestRun) { + return; + } + run.value = latestRun; + await onPoll?.(); + + if (isRunActive(latestRun)) { + start(); + return; + } + + stop(); + const runKey = latestRun.uuid ?? latestRun.id ?? latestRun.updatedAt; + if (runKey && terminalToastShownFor.value === runKey) { + return; + } + terminalToastShownFor.value = runKey; + + if (latestRun.status === 'completed') { + toast.add({ + severity: 'success', + summary: 'Suggestions ready', + detail: `${latestRun.completedCells} cells completed`, + life: 3000, + }); + } else if (latestRun.status === 'failed') { + toast.add({ + severity: 'error', + summary: 'Generation failed', + detail: latestRun.error ?? 'Dashboard suggestion generation failed.', + life: 5000, + }); + } + } catch { + stop(); + } + } + + function start() { + if (timer.value) { + return; + } + timer.value = window.setInterval(() => { + void pollLatest(); + }, 3000); + } + + watch( + run, + (nextRun) => { + if (isRunActive(nextRun)) { + start(); + } else { + stop(); + } + }, + { immediate: true }, + ); + + onUnmounted(stop); + + return { + run, + isPolling: computed(() => Boolean(timer.value)), + isLoading, + error, + progressPercent, + pollLatest, + start, + stop, + }; +} diff --git a/src/router/__tests__/index.spec.ts b/src/router/__tests__/index.spec.ts index 33cbe07f..1bf28353 100644 --- a/src/router/__tests__/index.spec.ts +++ b/src/router/__tests__/index.spec.ts @@ -36,4 +36,19 @@ describe('router', () => { }).path, ).toBe('/system/components/dashboards/component-1'); }); + + it('registers the dashboard suggestions review route behind auth meta', () => { + const route = router + .getRoutes() + .find((route) => route.name === 'dashboards.suggestions'); + + expect(route?.path).toBe('/dashboards/suggestions/:sspId'); + expect(route?.meta.requiresAuth).toBe(true); + expect( + router.resolve({ + name: 'dashboards.suggestions', + params: { sspId: 'ssp-1' }, + }).path, + ).toBe('/dashboards/suggestions/ssp-1'); + }); }); diff --git a/src/router/index.ts b/src/router/index.ts index 01b6cbb3..5c02a8e8 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -37,6 +37,14 @@ const authenticatedRoutes = [ requiresAuth: true, }, }, + { + path: '/dashboards/suggestions/:sspId', + name: 'dashboards.suggestions', + component: () => import('../views/dashboard/SuggestionsView.vue'), + meta: { + requiresAuth: true, + }, + }, { path: '/dashboards/:id', name: 'dashboards.view', diff --git a/src/stores/ai-config.ts b/src/stores/ai-config.ts new file mode 100644 index 00000000..c7382e62 --- /dev/null +++ b/src/stores/ai-config.ts @@ -0,0 +1,45 @@ +import { defineStore } from 'pinia'; +import { computed, ref } from 'vue'; +import { useDataApi } from '@/composables/axios'; +import { buildDashboardSuggestionsConfigEndpoint } from '@/views/dashboard/partials/dashboard-suggestions'; + +interface DashboardSuggestionsConfig { + enabled: boolean; +} + +export const useAiConfigStore = defineStore('ai-config', () => { + const fetched = ref(false); + const enabled = ref(false); + + const { execute, isLoading, error } = useDataApi( + null, + null, + { immediate: false }, + ); + + async function fetchDashboardSuggestionsConfig() { + if (fetched.value) { + return enabled.value; + } + + try { + const response = await execute(buildDashboardSuggestionsConfigEndpoint()); + const config = response?.data.value?.data; + enabled.value = Boolean(config?.enabled); + } catch { + enabled.value = false; + } finally { + fetched.value = true; + } + + return enabled.value; + } + + return { + dashboardSuggestionsEnabled: computed(() => enabled.value), + dashboardSuggestionsConfigFetched: computed(() => fetched.value), + dashboardSuggestionsConfigLoading: isLoading, + dashboardSuggestionsConfigError: error, + fetchDashboardSuggestionsConfig, + }; +}); diff --git a/src/stores/filters.ts b/src/stores/filters.ts index 18512962..c6ce6d1f 100644 --- a/src/stores/filters.ts +++ b/src/stores/filters.ts @@ -5,6 +5,7 @@ export interface Dashboard { id?: string; uuid?: string; name: string; + sspId?: string | null; filter: Filter; controls: Control[]; components: SystemComponent[]; diff --git a/src/views/dashboard/CreateFormView.vue b/src/views/dashboard/CreateFormView.vue index f6eb1f89..4950f887 100644 --- a/src/views/dashboard/CreateFormView.vue +++ b/src/views/dashboard/CreateFormView.vue @@ -10,6 +10,16 @@ +
+ + ' }, + RouterLink: { props: ['to'], template: '' }, + }, + }, + }); +} + +describe('Dashboard IndexView', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.aiEnabled = false; + dashboards.value = []; + systemSecurityPlans.value = []; + }); + + it('hides the AI suggestions entry point when the config probe is disabled', () => { + dashboards.value = [makeDashboard('Global dashboard')]; + const wrapper = mountView(); + + expect(wrapper.text()).not.toContain('AI suggestions'); + expect(wrapper.text()).toContain('Global dashboard'); + }); + + it('groups dashboards by global and owning SSP scopes', () => { + mocks.aiEnabled = true; + dashboards.value = [ + makeDashboard('Global dashboard'), + makeDashboard('Scoped dashboard', 'ssp-1'), + ]; + systemSecurityPlans.value = [makeSsp('ssp-1', 'Payments SSP')]; + + const wrapper = mountView(); + + expect( + wrapper.find('[data-testid="dashboard-group-global"]').text(), + ).toContain('Global'); + expect( + wrapper.find('[data-testid="dashboard-group-ssp-1"]').text(), + ).toContain('Payments SSP'); + expect(wrapper.text()).toContain('AI suggestions'); + }); +}); diff --git a/src/views/dashboard/__tests__/SuggestionsView.spec.ts b/src/views/dashboard/__tests__/SuggestionsView.spec.ts new file mode 100644 index 00000000..6f8c1090 --- /dev/null +++ b/src/views/dashboard/__tests__/SuggestionsView.spec.ts @@ -0,0 +1,177 @@ +import { mount } from '@vue/test-utils'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { nextTick } from 'vue'; +import type { SystemSecurityPlan } from '@/oscal'; + +const state = vi.hoisted(() => ({ + pendingSuggestions: { value: [] as unknown[] }, + historySuggestions: { value: [] as unknown[] }, + labelSets: { value: [] as unknown[] }, + ssp: { value: undefined as unknown }, + acceptSuggestions: vi.fn(), + rejectSuggestions: vi.fn(), + generateSuggestions: vi.fn(), + fetchSuggestionEvents: vi.fn(), + refreshPendingSuggestions: vi.fn(), + pollLatest: vi.fn(), + start: vi.fn(), + fetchConfig: vi.fn(), +})); + +vi.mock('vue-router', () => ({ + useRoute: () => ({ params: { sspId: 'ssp-1' } }), +})); + +vi.mock('@/stores/ai-config', () => ({ + useAiConfigStore: () => ({ + dashboardSuggestionsEnabled: true, + dashboardSuggestionsConfigFetched: true, + fetchDashboardSuggestionsConfig: state.fetchConfig, + }), +})); + +vi.mock('@/composables/axios', () => ({ + useDataApi: () => ({ data: state.ssp }), +})); + +vi.mock('@/composables/useDashboardSuggestions', () => ({ + useDashboardSuggestions: () => ({ + pendingSuggestions: state.pendingSuggestions, + historySuggestions: state.historySuggestions, + labelSets: state.labelSets, + pendingSuggestionsLoading: { value: false }, + historySuggestionsLoading: { value: false }, + generating: { value: false }, + refreshPendingSuggestions: state.refreshPendingSuggestions, + generateSuggestions: state.generateSuggestions, + acceptSuggestions: state.acceptSuggestions, + rejectSuggestions: state.rejectSuggestions, + fetchSuggestionEvents: state.fetchSuggestionEvents, + }), + useSuggestionRunPoller: () => ({ + run: { value: undefined }, + progressPercent: { value: 0 }, + pollLatest: state.pollLatest, + start: state.start, + }), +})); + +import SuggestionsView from '../SuggestionsView.vue'; + +function mountView() { + return mount(SuggestionsView, { + global: { + stubs: { + PageHeader: { template: '

' }, + PageSubHeader: { template: '

' }, + PageCard: { template: '
' }, + Chip: { props: ['label'], template: '{{ label }}' }, + Message: { template: '
' }, + SuggestionScopeDialog: { template: '
' }, + Dialog: { template: '
' }, + Textarea: { + props: ['modelValue'], + emits: ['update:modelValue'], + template: + '