From 5ba89d788d9f638255887994324e57430ad34c6b Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Thu, 18 Jun 2026 09:17:38 -0300 Subject: [PATCH 1/2] feat: dashboard edits Signed-off-by: Gustavo Carvalho --- src/composables/useDashboardSuggestions.ts | 99 ++++++- src/views/dashboard/SuggestionsView.vue | 226 +++++++++++++-- .../__tests__/SuggestionsView.spec.ts | 21 +- .../partials/LabelConditionBuilder.vue | 124 ++++++++ .../partials/SuggestionEditDialog.vue | 267 ++++++++++++++++++ .../partials/SuggestionScopeDialog.vue | 220 +++++++++------ .../__tests__/LabelConditionBuilder.spec.ts | 90 ++++++ .../__tests__/SuggestionEditDialog.spec.ts | 111 ++++++++ .../__tests__/SuggestionScopeDialog.spec.ts | 182 +++++++----- .../__tests__/dashboard-suggestions.spec.ts | 108 +++++++ .../partials/dashboard-suggestions.ts | 196 +++++++++++++ 11 files changed, 1455 insertions(+), 189 deletions(-) create mode 100644 src/views/dashboard/partials/LabelConditionBuilder.vue create mode 100644 src/views/dashboard/partials/SuggestionEditDialog.vue create mode 100644 src/views/dashboard/partials/__tests__/LabelConditionBuilder.spec.ts create mode 100644 src/views/dashboard/partials/__tests__/SuggestionEditDialog.spec.ts create mode 100644 src/views/dashboard/partials/__tests__/dashboard-suggestions.spec.ts diff --git a/src/composables/useDashboardSuggestions.ts b/src/composables/useDashboardSuggestions.ts index 8fbe9231..bd7b8cd2 100644 --- a/src/composables/useDashboardSuggestions.ts +++ b/src/composables/useDashboardSuggestions.ts @@ -16,13 +16,19 @@ import { buildDashboardSuggestionEventsEndpoint, buildDashboardSuggestionLabelSetsEndpoint, buildDashboardSuggestionsEndpoint, + buildDashboardSuggestionLabelKeysEndpoint, + buildEditDashboardSuggestionGroupEndpoint, buildGenerateDashboardSuggestionsEndpoint, + buildGeneralizeDashboardSuggestionsEndpoint, buildLatestDashboardSuggestionRunEndpoint, buildRejectDashboardSuggestionsEndpoint, type DashboardSuggestion, type DashboardSuggestionEvent, + type DashboardSuggestionLabelKey, type DashboardSuggestionLabelSet, + type EditDashboardSuggestionGroupPayload, type GenerateDashboardSuggestionsPayload, + type GeneralizeDashboardSuggestionsResult, type SuggestionRun, isRunActive, runCellFailures, @@ -45,9 +51,17 @@ export function useDashboardSuggestions( execute: fetchLabelSets, isLoading: labelSetsLoading, } = useDataApi(null, {}, { immediate: false }); + const { + data: labelKeys, + execute: fetchLabelKeys, + isLoading: labelKeysLoading, + } = useDataApi(null, {}, { immediate: false }); const { execute: generateRequest, isLoading: generating } = useDataApi(null, {}, { immediate: false }); + const { execute: generalizeRequest, isLoading: generalizing } = useDataApi< + SuggestionRun & GeneralizeDashboardSuggestionsResult + >(null, {}, { immediate: false }); const { execute: acceptRequest, isLoading: accepting } = useDataApi( null, {}, @@ -58,6 +72,9 @@ export function useDashboardSuggestions( {}, { immediate: false }, ); + const { execute: editGroupRequest, isLoading: editingGroup } = useDataApi< + DashboardSuggestion[] + >(null, {}, { immediate: false }); const { execute: eventsRequest, isLoading: loadingEvents } = useDataApi< DashboardSuggestionEvent[] >(null, {}, { immediate: false }); @@ -73,10 +90,16 @@ export function useDashboardSuggestions( return fetchPendingSuggestions( buildDashboardSuggestionsEndpoint(sspId.value, 'pending'), - // Preserve raw label keys (e.g. `_policy`) on both the originating label - // set and the proposed filter; otherwise camelcase conversion strips `_`. + // 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. { - camelcaseStopPaths: ['data.labelSet', 'data.proposedFilterLabelSet'], + camelcaseStopPaths: [ + 'data.labelSet', + 'data.proposedFilterLabelSet', + 'data.originalProposedFilterLabelSet', + ], }, ); } @@ -94,6 +117,18 @@ export function useDashboardSuggestions( ); } + async function refreshLabelKeys() { + if (!canFetchSuggestions()) { + return; + } + + // `key`/`values` are plain string fields; their values (label names like + // `_policy`) are not object keys, so camelize leaves them intact. + return fetchLabelKeys( + buildDashboardSuggestionLabelKeysEndpoint(sspId.value), + ); + } + async function generateSuggestions( payload: GenerateDashboardSuggestionsPayload, ) { @@ -111,6 +146,27 @@ export function useDashboardSuggestions( ); } + // Triggers the deterministic filter-merge detector, which inserts pending + // generalization suggestions for near-duplicate filters. Returns the result + // counts so the caller can toast how many merges were found. + async function generalizeSuggestions(): Promise< + GeneralizeDashboardSuggestionsResult | undefined + > { + if (!canFetchSuggestions()) { + return; + } + + const response = await generalizeRequest( + buildGeneralizeDashboardSuggestionsEndpoint(sspId.value), + { method: 'POST' }, + ); + await refreshPendingSuggestions(); + const data = response?.data.value?.data; + return data + ? { candidates: data.candidates, inserted: data.inserted } + : undefined; + } + async function acceptSuggestions(ids: string[]) { if (!canFetchSuggestions()) { return; @@ -138,7 +194,11 @@ export function useDashboardSuggestions( const response = await fetchHistoryRequest( buildDashboardSuggestionsEndpoint(sspId.value, status), { - camelcaseStopPaths: ['data.labelSet', 'data.proposedFilterLabelSet'], + camelcaseStopPaths: [ + 'data.labelSet', + 'data.proposedFilterLabelSet', + 'data.originalProposedFilterLabelSet', + ], }, ); collected.push(...(response?.data.value?.data ?? [])); @@ -161,6 +221,30 @@ export function useDashboardSuggestions( await refreshHistorySuggestions(); } + async function editSuggestionGroup( + payload: EditDashboardSuggestionGroupPayload, + ) { + if (!canFetchSuggestions()) { + return; + } + + await editGroupRequest( + buildEditDashboardSuggestionGroupEndpoint(sspId.value), + { + method: 'POST', + data: payload, + transformRequest: [decamelizeKeys], + // Preserve raw label keys (e.g. `_policy`) the user kept in the filter. + camelcaseStopPaths: [ + 'data.labelSet', + 'data.proposedFilterLabelSet', + 'data.originalProposedFilterLabelSet', + ], + }, + ); + await refreshPendingSuggestions(); + } + async function fetchSuggestionEvents(suggestionId: string) { if (!canFetchSuggestions()) { return []; @@ -182,19 +266,26 @@ export function useDashboardSuggestions( pendingSuggestions, historySuggestions, labelSets, + labelKeys, pendingSuggestionsLoading, historySuggestionsLoading, labelSetsLoading, + labelKeysLoading, generating, + generalizing, accepting, rejecting, + editingGroup, loadingEvents, refreshPendingSuggestions, refreshHistorySuggestions, refreshLabelSets, + refreshLabelKeys, generateSuggestions, + generalizeSuggestions, acceptSuggestions, rejectSuggestions, + editSuggestionGroup, fetchSuggestionEvents, }; } diff --git a/src/views/dashboard/SuggestionsView.vue b/src/views/dashboard/SuggestionsView.vue index 2e4ccc5a..c7f551f4 100644 --- a/src/views/dashboard/SuggestionsView.vue +++ b/src/views/dashboard/SuggestionsView.vue @@ -4,15 +4,24 @@ Dashboard suggestions {{ sspTitle }} - - Generate suggestions - + + {{ generalizing ? 'Finding merges…' : 'Suggest filter merges' }} + + + Generate suggestions + +

{{ groupTitle(group.suggestions[0]) }}

+ + {{ diffFor(group).titleFrom }} + + + - +
+ + {{ chip.text }} +
+

+ {{ group.suggestions[0]?.reasoning }} + Accepting moves these controls onto the generalized filter and off + the source filters. +

+ + Edit + Accept group @@ -185,6 +232,12 @@ {{ suggestion.controlId }} {{ suggestion.controlTitle }} + + Added + {{ confidenceLabel(suggestion.confidence) }} @@ -213,6 +266,20 @@
+ +
+ Removed controls: + + {{ controlId }} + +
@@ -265,13 +332,23 @@ v-model:visible="showScopeDialog" :ssp-id="sspId" :controls="controlOptions" - :labelSets="labelSets ?? []" + :label-keys="labelKeys ?? []" :generating="generating" :ceilingError="scopeCeilingError" @scope-change="scopeCeilingError = ''" @generate="generate" /> + + import { computed, onMounted, ref, watch } from 'vue'; import { RouterLink, useRoute } from 'vue-router'; +import { useToast } from 'primevue/usetoast'; import PageCard from '@/components/PageCard.vue'; import PageHeader from '@/components/PageHeader.vue'; import PageSubHeader from '@/components/PageSubHeader.vue'; @@ -351,15 +429,21 @@ import PrimaryButton from '@/volt/PrimaryButton.vue'; import SecondaryButton from '@/volt/SecondaryButton.vue'; import Textarea from '@/volt/Textarea.vue'; import SuggestionScopeDialog from './partials/SuggestionScopeDialog.vue'; +import SuggestionEditDialog from './partials/SuggestionEditDialog.vue'; import { + buildControlKey, + computeGroupEditDiff, formatLabelSet, runCellFailures, type DashboardSuggestion, type DashboardSuggestionEvent, + type EditDashboardSuggestionGroupPayload, type GenerateDashboardSuggestionsPayload, + type LabelChipKind, } from './partials/dashboard-suggestions'; const route = useRoute(); +const toast = useToast(); const sspId = computed(() => String(route.params.sspId)); const aiConfig = useAiConfigStore(); const axios = useAuthenticatedInstance(); @@ -373,8 +457,11 @@ const selectedSuggestionIds = ref([]); const expandedReasoning = ref(new Set()); const auditEvents = ref([]); const scopeCeilingError = ref(''); +const showEditDialog = ref(false); +const editGroup = ref(null); const controlTitleById = ref(new Map()); const controlCatalogById = ref(new Map()); +const controlCatalogIdById = ref(new Map()); const controlProfileTitlesById = ref(new Map()); interface SspProfileBinding { @@ -389,6 +476,15 @@ interface ResolvedControlWithCatalog { catalogId?: string; } +interface PendingGroup { + hash: string; + labels: string[]; + filterLabelsObject: Record; + evidenceCount: number; + sampleTitles: string[]; + suggestions: DashboardSuggestion[]; +} + const { data: systemSecurityPlan } = useDataApi( computed(() => `/api/oscal/system-security-plans/${sspId.value}/full`), ); @@ -397,15 +493,21 @@ const { pendingSuggestions, historySuggestions, labelSets, + labelKeys, pendingSuggestionsLoading, historySuggestionsLoading, generating, + generalizing, refreshPendingSuggestions, refreshHistorySuggestions, refreshLabelSets, + refreshLabelKeys, generateSuggestions, + generalizeSuggestions, acceptSuggestions, rejectSuggestions, + editSuggestionGroup, + editingGroup, fetchSuggestionEvents, } = useDashboardSuggestions( sspId, @@ -446,6 +548,7 @@ const controlOptions = computed(() => const controlKey = normalizeControlId(requirement.controlId); const title = controlTitleById.value.get(controlKey); const catalogTitle = controlCatalogById.value.get(controlKey); + const catalogId = controlCatalogIdById.value.get(controlKey); const profileTitles = controlProfileTitlesById.value.get(controlKey) ?? []; const titleLabel = title @@ -453,7 +556,11 @@ const controlOptions = computed(() => : requirement.controlId; return { label: titleLabel, - value: requirement.controlId, + // The scope API resolves controls by catalog-qualified key + // (`:`); send that, not the bare control id. + value: catalogId + ? buildControlKey(catalogId, requirement.controlId) + : requirement.controlId, controlId: requirement.controlId, title, catalogTitle, @@ -471,16 +578,7 @@ const labelSetByHash = computed( ); const pendingGroups = computed(() => { - const groups = new Map< - string, - { - hash: string; - labels: string[]; - evidenceCount: number; - sampleTitles: string[]; - suggestions: DashboardSuggestion[]; - } - >(); + const groups = new Map(); for (const suggestion of pendingSuggestions.value ?? []) { const proposedFilter = suggestion.proposedFilterLabelSet; @@ -504,16 +602,14 @@ const pendingGroups = computed(() => { sampleTitles: matched?.sampleTitles ?? [], }; + const filterLabelsObject = hasProposedFilter + ? proposedFilter + : (matched?.labels ?? suggestion.labelSet ?? suggestion.labels ?? {}); + group = { hash: key, - labels: formatLabelSet( - hasProposedFilter - ? proposedFilter - : (matched?.labels ?? - suggestion.labelSet ?? - suggestion.labels ?? - {}), - ), + labels: formatLabelSet(filterLabelsObject), + filterLabelsObject, evidenceCount: evidence.count, sampleTitles: evidence.sampleTitles, suggestions: [], @@ -557,6 +653,7 @@ onMounted(async () => { return; } await refreshLabelSets(); + await refreshLabelKeys(); await refreshHistorySuggestions(); await pollLatest(); if (run.value?.status === 'pending' || run.value?.status === 'running') { @@ -600,6 +697,7 @@ function normalizeControlId(controlId?: string) { async function loadControlMetadata() { controlTitleById.value = new Map(); controlCatalogById.value = new Map(); + controlCatalogIdById.value = new Map(); controlProfileTitlesById.value = new Map(); if (!sspId.value) { @@ -666,6 +764,7 @@ async function loadControlMetadata() { ); controlTitleById.value = titles; controlCatalogById.value = catalogTitles; + controlCatalogIdById.value = controlCatalogIds; controlProfileTitlesById.value = new Map( Array.from(profileTitles.entries()).map(([controlId, profiles]) => [ controlId, @@ -809,6 +908,21 @@ function groupIds(group: { suggestions: DashboardSuggestion[] }) { return group.suggestions.map((suggestion) => suggestion.id); } +function diffFor(group: PendingGroup) { + return computeGroupEditDiff(group.suggestions, group.filterLabelsObject); +} + +function labelChipClass(kind: LabelChipKind) { + switch (kind) { + case 'added': + return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'; + case 'removed': + return 'bg-red-100 text-red-800 line-through dark:bg-red-900 dark:text-red-200'; + default: + return 'bg-zinc-100 text-zinc-700 dark:bg-slate-700 dark:text-slate-200'; + } +} + function confidenceLabel(confidence: number | undefined) { if (confidence === undefined || confidence === null) { return 'Confidence unavailable'; @@ -842,6 +956,43 @@ async function generate(payload: GenerateDashboardSuggestionsPayload) { } } +async function suggestFilterMerges() { + try { + const result = await generalizeSuggestions(); + activeTab.value = 'pending'; + if (!result || result.inserted === 0) { + toast.add({ + severity: 'info', + summary: 'No filter merges found', + detail: + 'No near-duplicate filters differ by a single generalizable label.', + life: 4000, + }); + return; + } + toast.add({ + severity: 'success', + summary: `${result.candidates} filter merge${result.candidates === 1 ? '' : 's'} proposed`, + detail: `${result.inserted} suggestion${result.inserted === 1 ? '' : 's'} added to the pending list.`, + life: 4000, + }); + } catch (error) { + toast.add({ + severity: 'error', + summary: 'Could not suggest filter merges', + detail: extractErrorMessage(error), + life: 6000, + }); + } +} + +// Builds the chip label for a generalization group from its source filter count +// and the dropped label key (parsed from the deterministic reasoning string). +function generalizationLabel(group: PendingGroup) { + const count = group.suggestions[0]?.sourceFilterIds?.length ?? 0; + return `Merges ${count} filter${count === 1 ? '' : 's'}`; +} + async function acceptOne(id: string) { await acceptSuggestions([id]); selectedSuggestionIds.value = selectedSuggestionIds.value.filter( @@ -877,6 +1028,25 @@ async function rejectPending() { showRejectDialog.value = false; } +function resolveControlCatalogId(controlId: string) { + return controlCatalogIdById.value.get(normalizeControlId(controlId)); +} + +function openEditDialog(group: PendingGroup) { + editGroup.value = group; + showEditDialog.value = true; +} + +async function saveGroupEdit(payload: EditDashboardSuggestionGroupPayload) { + try { + await editSuggestionGroup(payload); + selectedSuggestionIds.value = []; + showEditDialog.value = false; + } catch (error) { + scopeCeilingError.value = extractErrorMessage(error); + } +} + async function openAuditDialog(id: string) { auditEvents.value = await fetchSuggestionEvents(id); showAuditDialog.value = true; diff --git a/src/views/dashboard/__tests__/SuggestionsView.spec.ts b/src/views/dashboard/__tests__/SuggestionsView.spec.ts index 86cd3070..35fa1ab4 100644 --- a/src/views/dashboard/__tests__/SuggestionsView.spec.ts +++ b/src/views/dashboard/__tests__/SuggestionsView.spec.ts @@ -12,10 +12,13 @@ const state = vi.hoisted(() => ({ acceptSuggestions: vi.fn(), rejectSuggestions: vi.fn(), generateSuggestions: vi.fn(), + generalizeSuggestions: vi.fn(), + toastAdd: vi.fn(), fetchSuggestionEvents: vi.fn(), refreshPendingSuggestions: vi.fn(), refreshHistorySuggestions: vi.fn(), refreshLabelSets: vi.fn(), + refreshLabelKeys: vi.fn(), pollLatest: vi.fn(), start: vi.fn(), axiosGet: vi.fn(), @@ -49,18 +52,26 @@ vi.mock('@/composables/axios', () => ({ useDataApi: () => ({ data: state.ssp }), })); +vi.mock('primevue/usetoast', () => ({ + useToast: () => ({ add: state.toastAdd }), +})); + vi.mock('@/composables/useDashboardSuggestions', () => ({ useDashboardSuggestions: () => ({ pendingSuggestions: state.pendingSuggestions, historySuggestions: state.historySuggestions, labelSets: state.labelSets, + labelKeys: { value: [] }, pendingSuggestionsLoading: { value: false }, historySuggestionsLoading: { value: false }, generating: { value: false }, + generalizing: { value: false }, refreshPendingSuggestions: state.refreshPendingSuggestions, refreshHistorySuggestions: state.refreshHistorySuggestions, refreshLabelSets: state.refreshLabelSets, + refreshLabelKeys: state.refreshLabelKeys, generateSuggestions: state.generateSuggestions, + generalizeSuggestions: state.generalizeSuggestions, acceptSuggestions: state.acceptSuggestions, rejectSuggestions: state.rejectSuggestions, fetchSuggestionEvents: state.fetchSuggestionEvents, @@ -90,6 +101,11 @@ function mountView() { template: '
{{ control.label }}
', }, + SuggestionEditDialog: { + name: 'SuggestionEditDialog', + props: ['group', 'controlOptions'], + template: '
', + }, Dialog: { template: '
' }, Textarea: { props: ['modelValue'], @@ -357,9 +373,10 @@ describe('SuggestionsView', () => { profileTitles: string[]; }>; + // The option value is the catalog-qualified control key the scope API expects. expect(controls).toContainEqual({ label: 'ac-1 - Access control policy', - value: 'ac-1', + value: 'catalog-1:ac-1', controlId: 'ac-1', title: 'Access control policy', catalogTitle: 'Catalog', @@ -367,7 +384,7 @@ describe('SuggestionsView', () => { }); expect(controls).toContainEqual({ label: 'AC-2 - Account management', - value: 'AC-2', + value: 'catalog-1:AC-2', controlId: 'AC-2', title: 'Account management', catalogTitle: 'Catalog', diff --git a/src/views/dashboard/partials/LabelConditionBuilder.vue b/src/views/dashboard/partials/LabelConditionBuilder.vue new file mode 100644 index 00000000..67b331da --- /dev/null +++ b/src/views/dashboard/partials/LabelConditionBuilder.vue @@ -0,0 +1,124 @@ + + + diff --git a/src/views/dashboard/partials/SuggestionEditDialog.vue b/src/views/dashboard/partials/SuggestionEditDialog.vue new file mode 100644 index 00000000..6389f550 --- /dev/null +++ b/src/views/dashboard/partials/SuggestionEditDialog.vue @@ -0,0 +1,267 @@ + + + diff --git a/src/views/dashboard/partials/SuggestionScopeDialog.vue b/src/views/dashboard/partials/SuggestionScopeDialog.vue index 14440311..90cc081f 100644 --- a/src/views/dashboard/partials/SuggestionScopeDialog.vue +++ b/src/views/dashboard/partials/SuggestionScopeDialog.vue @@ -46,38 +46,18 @@
- - - - + +

+ Restrict which evidence feeds the suggestions with label conditions + (all conditions must match). Leave empty to consider all evidence. +

+
+
+

+ Suggestion constraints +

+
+ + ', + }, + SecondaryButton: { + template: '', + }, + }, + }, + }); + return { wrapper, rows }; +} + +describe('LabelConditionBuilder', () => { + beforeEach(() => { + vi.clearAllMocks(); + state.axiosGet.mockResolvedValue({ + data: { data: ['todo-app', 'todo-worker'] }, + }); + }); + + it('adds a row and captures free-typed key/value (no cap)', async () => { + const { wrapper, rows } = mountBuilder(); + + await wrapper.find('[data-testid="add-condition"]').trigger('click'); + expect(rows.value).toEqual([{ key: '', value: '' }]); + + const inputs = wrapper.find('[data-testid="condition-0"]').findAll('input'); + await inputs[0].setValue('repository'); + await inputs[1].setValue('todo-app'); + + expect(rows.value).toEqual([{ key: 'repository', value: 'todo-app' }]); + }); + + it('searches values server-side for the row key', async () => { + const { wrapper } = mountBuilder([{ key: 'repository', value: '' }]); + + const valueInput = wrapper + .find('[data-testid="condition-0"]') + .findAllComponents({ name: 'AutoComplete' })[1]; + valueInput.vm.$emit('complete', { query: 'todo' }); + await flushPromises(); + + expect(state.axiosGet).toHaveBeenCalledWith( + expect.stringContaining('/dashboard-suggestions/label-values'), + { params: { key: 'repository', query: 'todo' } }, + ); + }); + + it('removes a row', async () => { + const { wrapper, rows } = mountBuilder([ + { key: 'env', value: 'prod' }, + { key: 'repository', value: 'todo-app' }, + ]); + + await wrapper.find('[data-testid="condition-0"] button').trigger('click'); + expect(rows.value).toEqual([{ key: 'repository', value: 'todo-app' }]); + }); +}); diff --git a/src/views/dashboard/partials/__tests__/SuggestionEditDialog.spec.ts b/src/views/dashboard/partials/__tests__/SuggestionEditDialog.spec.ts new file mode 100644 index 00000000..13855a98 --- /dev/null +++ b/src/views/dashboard/partials/__tests__/SuggestionEditDialog.spec.ts @@ -0,0 +1,111 @@ +import { mount } from '@vue/test-utils'; +import { describe, expect, it } from 'vitest'; +import SuggestionEditDialog from '../SuggestionEditDialog.vue'; +import type { DashboardSuggestion } from '../dashboard-suggestions'; + +function makeSuggestion( + id: string, + controlId: string, + overrides: Partial = {}, +): DashboardSuggestion { + return { + id, + status: 'pending', + controlId, + controlCatalogId: 'cat-1', + labelSetHash: 'hash-1', + proposedFilterName: 'AI name', + proposedFilterLabelSet: { env: 'prod' }, + ...overrides, + }; +} + +function mountDialog() { + const group = { + hash: 'filter:env=prod', + labels: ['env=prod'], + suggestions: [ + makeSuggestion('id-1', 'AC-1'), + makeSuggestion('id-2', 'AC-2'), + ], + }; + return mount(SuggestionEditDialog, { + props: { + visible: true, + group, + controlOptions: [ + { label: 'AC-1', value: 'AC-1', controlId: 'AC-1' }, + { label: 'AC-2', value: 'AC-2', controlId: 'AC-2' }, + { label: 'AC-3', value: 'AC-3', controlId: 'AC-3' }, + ], + resolveCatalogId: () => 'cat-1', + }, + global: { + stubs: { + Dialog: { template: '
' }, + Message: { template: '
' }, + InputText: { + props: ['modelValue'], + emits: ['update:modelValue'], + template: + '', + }, + MultiSelect: { + name: 'MultiSelect', + props: ['modelValue', 'options', 'optionLabel', 'optionValue'], + emits: ['update:modelValue'], + template: '
', + }, + Checkbox: { + name: 'Checkbox', + props: ['modelValue'], + emits: ['update:modelValue'], + template: + '', + }, + PrimaryButton: { + props: ['disabled'], + template: + '', + }, + SecondaryButton: { + template: '', + }, + }, + }, + }); +} + +describe('SuggestionEditDialog', () => { + it('emits an edit payload with human-override labels, added and removed controls', async () => { + const wrapper = mountDialog(); + + // Override the label value (env=prod -> env=staging) and the title. + await wrapper + .find('[data-testid="edit-label-value-0"]') + .setValue('staging'); + await wrapper.find('#edit-title').setValue('Staging payments'); + + // Remove AC-2 (the second member checkbox). + const checkboxes = wrapper.findAll('input[type="checkbox"]'); + await checkboxes[1].setValue(false); + + // Add AC-3 via the MultiSelect. + wrapper + .findComponent({ name: 'MultiSelect' }) + .vm.$emit('update:modelValue', ['AC-3']); + await wrapper.vm.$nextTick(); + + await wrapper.find('[data-testid="save"]').trigger('click'); + + const saved = wrapper.emitted('save'); + expect(saved).toBeTruthy(); + expect(saved?.[0][0]).toEqual({ + ids: ['id-1', 'id-2'], + proposedFilterName: 'Staging payments', + proposedFilterLabelSet: { env: 'staging' }, + addControlKeys: ['cat-1:AC-3'], + removeIds: ['id-2'], + }); + }); +}); diff --git a/src/views/dashboard/partials/__tests__/SuggestionScopeDialog.spec.ts b/src/views/dashboard/partials/__tests__/SuggestionScopeDialog.spec.ts index cbdc663b..57edaaad 100644 --- a/src/views/dashboard/partials/__tests__/SuggestionScopeDialog.spec.ts +++ b/src/views/dashboard/partials/__tests__/SuggestionScopeDialog.spec.ts @@ -1,7 +1,7 @@ import { flushPromises, mount } from '@vue/test-utils'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import SuggestionScopeDialog from '../SuggestionScopeDialog.vue'; -import type { DashboardSuggestionLabelSet } from '../dashboard-suggestions'; +import type { DashboardSuggestionLabelKey } from '../dashboard-suggestions'; const state = vi.hoisted(() => ({ axiosPost: vi.fn(), @@ -13,17 +13,17 @@ const state = vi.hoisted(() => ({ })); vi.mock('@/composables/axios', () => ({ - useAuthenticatedInstance: () => ({ post: state.axiosPost }), + useAuthenticatedInstance: () => ({ + post: state.axiosPost, + get: vi.fn(async () => ({ data: { data: [] } })), + }), decamelizeKeys: (data: unknown) => data, })); -function makeLabelSet(hash: string): DashboardSuggestionLabelSet { - return { - hash, - labels: { env: hash }, - evidenceCount: 2, - }; -} +const labelKeys: DashboardSuggestionLabelKey[] = [ + { key: 'env', values: ['prod', 'stage'] }, + { key: 'provider', values: ['aws'] }, +]; function mountDialog(props = {}) { return mount(SuggestionScopeDialog, { @@ -41,7 +41,7 @@ function mountDialog(props = {}) { }, { label: 'AC-2', value: 'AC-2' }, ], - labelSets: [makeLabelSet('hash-1'), makeLabelSet('hash-2')], + labelKeys, ...props, }, global: { @@ -56,6 +56,21 @@ function mountDialog(props = {}) { template: '
{{ option[optionLabel] }}
', }, + // Free-text input stub: covers both the scope-preset Select and the + // editable condition key/value Selects. + Select: { + name: 'Select', + props: ['modelValue', 'options'], + emits: ['update:modelValue'], + template: + '', + }, + LabelConditionBuilder: { + name: 'LabelConditionBuilder', + props: ['modelValue', 'sspId', 'keys'], + emits: ['update:modelValue'], + template: '
', + }, Checkbox: { name: 'Checkbox', props: ['modelValue'], @@ -92,15 +107,10 @@ describe('SuggestionScopeDialog', () => { labelSetCount: undefined, }; state.axiosPost.mockImplementation(async (_url: string, body: unknown) => { - const scope = ( - body as { - scope?: { controlKeys?: string[]; labelSetHashes?: string[] }; - } - )?.scope; + const scope = (body as { scope?: { controlKeys?: string[] } })?.scope; const controlCount = state.preview.controlCount ?? scope?.controlKeys?.length ?? 2; - const labelSetCount = - state.preview.labelSetCount ?? scope?.labelSetHashes?.length ?? 2; + const labelSetCount = state.preview.labelSetCount ?? 2; return { data: { data: { @@ -135,7 +145,6 @@ describe('SuggestionScopeDialog', () => { const wrapper = mountDialog(); await flushPreview(); - // Deselect a control so a non-empty scope is sent. await wrapper .findAllComponents({ name: 'MultiSelect' })[0] .vm.$emit('update:modelValue', ['AC-1']); @@ -143,24 +152,49 @@ describe('SuggestionScopeDialog', () => { const lastCall = state.axiosPost.mock.calls.at(-1); expect(lastCall?.[1]).toMatchObject({ scope: { controlKeys: ['AC-1'] } }); - // Without this transform the keys go out camelCase and the API 422s. expect(lastCall?.[2]?.transformRequest).toBeDefined(); }); + it('builds an evidence label filter from conditions in the generate payload', async () => { + const wrapper = mountDialog(); + await flushPreview(); + + // The first LabelConditionBuilder is the "Evidence to analyze" filter. + wrapper + .findAllComponents({ name: 'LabelConditionBuilder' })[0] + .vm.$emit('update:modelValue', [ + { key: 'repository', value: 'todo-app' }, + ]); + await flushPreview(); + + await wrapper.find('[data-testid="scope-generate"]').trigger('click'); + + expect(wrapper.emitted('generate')?.at(-1)).toEqual([ + { + supersedePending: true, + scope: { + labelFilter: { + scope: { + condition: { + label: 'repository', + operator: '=', + value: 'todo-app', + }, + }, + }, + }, + constraints: undefined, + }, + ]); + }); + it('requires explicit confirmation for larger grids', async () => { - state.preview = { - plannedCalls: 30, - controlCount: 6, - labelSetCount: 5, - }; + state.preview = { plannedCalls: 30, controlCount: 6, labelSetCount: 5 }; const wrapper = mountDialog({ controls: Array.from({ length: 6 }, (_, index) => ({ label: `AC-${index}`, value: `AC-${index}`, })), - labelSets: Array.from({ length: 5 }, (_, index) => - makeLabelSet(`hash-${index}`), - ), }); await flushPreview(); @@ -174,10 +208,56 @@ describe('SuggestionScopeDialog', () => { .vm.$emit('update:modelValue', true); await wrapper.find('[data-testid="scope-generate"]').trigger('click'); + expect(wrapper.emitted('generate')?.at(-1)).toEqual([ + { supersedePending: true, scope: undefined, constraints: undefined }, + ]); + }); + + it('includes label constraints and a scope preset in the generate payload', async () => { + const wrapper = mountDialog(); + await flushPreview(); + + // Pick the "only new filters" preset. + await wrapper.find('[data-testid="scope-preset"]').setValue('new_filter'); + // Required labels is the 2nd LabelConditionBuilder (evidence is the 1st). + wrapper + .findAllComponents({ name: 'LabelConditionBuilder' })[1] + .vm.$emit('update:modelValue', [{ key: 'env', value: 'prod' }]); + await flushPreview(); + + await wrapper.find('[data-testid="scope-generate"]').trigger('click'); + expect(wrapper.emitted('generate')?.at(-1)).toEqual([ { supersedePending: true, scope: undefined, + constraints: { + onlyAction: 'new_filter', + mandatoryLabels: [{ key: 'env', value: 'prod' }], + }, + }, + ]); + }); + + it('treats a blank condition value as a key-only (any value) selector', async () => { + const wrapper = mountDialog(); + await flushPreview(); + + // Excluded labels is the 3rd LabelConditionBuilder. + wrapper + .findAllComponents({ name: 'LabelConditionBuilder' })[2] + .vm.$emit('update:modelValue', [{ key: 'repository', value: '' }]); + await flushPreview(); + + await wrapper.find('[data-testid="scope-generate"]').trigger('click'); + + expect(wrapper.emitted('generate')?.at(-1)).toEqual([ + { + supersedePending: true, + scope: undefined, + constraints: { + excludedLabels: [{ key: 'repository', value: null }], + }, }, ]); }); @@ -225,19 +305,12 @@ describe('SuggestionScopeDialog', () => { }); it('uses preview planned calls for large-run confirmation instead of local cartesian size', async () => { - state.preview = { - plannedCalls: 1, - controlCount: 16, - labelSetCount: 89, - }; + state.preview = { plannedCalls: 1, controlCount: 16, labelSetCount: 89 }; const wrapper = mountDialog({ controls: Array.from({ length: 16 }, (_, index) => ({ label: `AC-${index}`, value: `AC-${index}`, })), - labelSets: Array.from({ length: 89 }, (_, index) => - makeLabelSet(`hash-${index}`), - ), }); await flushPreview(); @@ -248,10 +321,7 @@ describe('SuggestionScopeDialog', () => { await wrapper.find('[data-testid="scope-generate"]').trigger('click'); expect(wrapper.emitted('generate')?.at(-1)).toEqual([ - { - supersedePending: true, - scope: undefined, - }, + { supersedePending: true, scope: undefined, constraints: undefined }, ]); }); @@ -266,38 +336,4 @@ describe('SuggestionScopeDialog', () => { await wrapper.find('[data-testid="scope-generate"]').trigger('click'); expect(wrapper.emitted('generate')).toBeUndefined(); }); - - it('shows human label-set titles instead of selected hash labels', () => { - const wrapper = mountDialog({ - labelSets: [ - { - hash: 'opaque-hash', - labels: { env: 'prod', service: 'payments' }, - evidenceCount: 2, - sampleTitles: ['Payment evidence'], - }, - { - hash: 'fallback-hash', - labels: { env: 'stage' }, - evidenceCount: 1, - }, - ], - }); - - const labelSetSelector = wrapper.findAllComponents({ - name: 'MultiSelect', - })[1]; - const labelSetOptions = labelSetSelector.props('options') as Array<{ - title: string; - }>; - - expect(labelSetOptions.map((option) => option.title)).toEqual([ - 'Payment evidence', - 'env=stage', - ]); - expect(labelSetSelector.text()).toContain('Payment evidence'); - expect(labelSetSelector.text()).toContain('env=prod'); - // Evidence counts are no longer rendered (label sets are 1-for-1 with evidence). - expect(labelSetSelector.text()).not.toContain('2 evidence'); - }); }); diff --git a/src/views/dashboard/partials/__tests__/dashboard-suggestions.spec.ts b/src/views/dashboard/partials/__tests__/dashboard-suggestions.spec.ts new file mode 100644 index 00000000..0dab0e8a --- /dev/null +++ b/src/views/dashboard/partials/__tests__/dashboard-suggestions.spec.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import { + buildLabelFilter, + computeGroupEditDiff, + type DashboardSuggestion, +} from '../dashboard-suggestions'; + +describe('buildLabelFilter', () => { + it('returns undefined when there are no valid conditions', () => { + expect(buildLabelFilter([])).toBeUndefined(); + expect(buildLabelFilter([{ key: '', value: 'x' }])).toBeUndefined(); + expect(buildLabelFilter([{ key: 'env', value: ' ' }])).toBeUndefined(); + }); + + it('collapses a single condition to a bare scope.condition', () => { + expect(buildLabelFilter([{ key: 'env', value: 'prod' }])).toEqual({ + scope: { condition: { label: 'env', operator: '=', value: 'prod' } }, + }); + }); + + it('AND-joins multiple conditions under a scope.query', () => { + expect( + buildLabelFilter([ + { key: 'env', value: 'prod' }, + { key: 'provider', value: 'aws' }, + ]), + ).toEqual({ + scope: { + query: { + operator: 'AND', + scopes: [ + { condition: { label: 'env', operator: '=', value: 'prod' } }, + { condition: { label: 'provider', operator: '=', value: 'aws' } }, + ], + }, + }, + }); + }); +}); + +function suggestion( + overrides: Partial, +): DashboardSuggestion { + return { + id: overrides.id ?? 'id', + status: 'pending', + controlId: overrides.controlId ?? 'AC-1', + labelSetHash: 'hash', + ...overrides, + }; +} + +describe('computeGroupEditDiff', () => { + it('returns all unchanged chips and no diff for an un-edited group', () => { + const diff = computeGroupEditDiff( + [ + suggestion({ + proposedFilterName: 'AI', + proposedFilterLabelSet: { env: 'prod' }, + }), + ], + { env: 'prod' }, + ); + expect(diff.edited).toBe(false); + expect(diff.labelChips).toEqual([{ text: 'env=prod', kind: 'unchanged' }]); + expect(diff.titleTo).toBeUndefined(); + expect(diff.addedControlIds).toEqual([]); + expect(diff.removedControlIds).toEqual([]); + }); + + it('marks added, removed and changed labels and the title change', () => { + const kept = suggestion({ + id: 'k', + isUserEdited: true, + proposedFilterName: 'User title', + originalProposedFilterName: 'AI title', + proposedFilterLabelSet: { env: 'staging', team: 'payments' }, + originalProposedFilterLabelSet: { env: 'prod', repo: 'payments-api' }, + removedControlIds: ['AC-2'], + }); + const added = suggestion({ + id: 'a', + controlId: 'AC-3', + isUserEdited: true, + addedByUser: true, + proposedFilterLabelSet: { env: 'staging', team: 'payments' }, + }); + + const diff = computeGroupEditDiff([kept, added], { + env: 'staging', + team: 'payments', + }); + + expect(diff.edited).toBe(true); + expect(diff.titleFrom).toBe('AI title'); + expect(diff.titleTo).toBe('User title'); + expect(diff.addedControlIds).toEqual(['AC-3']); + expect(diff.removedControlIds).toEqual(['AC-2']); + // env=prod -> env=staging (changed: red old + green new); repo removed (red); + // team added (green). + expect(diff.labelChips).toEqual([ + { text: 'env=prod', kind: 'removed' }, + { text: 'env=staging', kind: 'added' }, + { text: 'repo=payments-api', kind: 'removed' }, + { text: 'team=payments', kind: 'added' }, + ]); + }); +}); diff --git a/src/views/dashboard/partials/dashboard-suggestions.ts b/src/views/dashboard/partials/dashboard-suggestions.ts index 3e40c9fa..f193acc9 100644 --- a/src/views/dashboard/partials/dashboard-suggestions.ts +++ b/src/views/dashboard/partials/dashboard-suggestions.ts @@ -1,3 +1,7 @@ +import type { Filter as LabelFilter } from '@/parsers/labelfilter'; + +export type { LabelFilter }; + export type DashboardSuggestionStatus = | 'pending' | 'accepted' @@ -18,14 +22,56 @@ export interface DashboardSuggestionLabelSet { sampleTitles?: string[]; } +// A distinct evidence label key with its distinct values, used to populate the +// evidence-scoping filter builder without loading every label set. +export interface DashboardSuggestionLabelKey { + key: string; + values: string[]; +} + +// One key=value row in a label-condition builder (value "" means "any value"). +export interface LabelConditionRow { + key: string; + value: string; +} + export interface DashboardSuggestionScope { controlKeys?: string[]; labelSetHashes?: string[]; + // Evidence-scoping label filter (same shape as the evidence-search filter). + labelFilter?: LabelFilter; +} + +// A mandatory/excluded label selector. A null/undefined value matches any value +// for the key (key-only selector); a string value requires an exact match. +export interface LabelSelector { + key: string; + value?: string | null; +} + +export type SuggestionActionScope = '' | 'new_filter' | 'extend_filter'; + +export interface DashboardSuggestionConstraints { + mandatoryLabels?: LabelSelector[]; + excludedLabels?: LabelSelector[]; + // Restrict suggestions to creating new filters or extending existing ones. + onlyAction?: SuggestionActionScope; + // Scope generation to controls that have no dashboard filter attached. + onlyControlsWithoutFilters?: boolean; } export interface GenerateDashboardSuggestionsPayload { supersedePending?: boolean; scope?: DashboardSuggestionScope; + constraints?: DashboardSuggestionConstraints; +} + +export interface EditDashboardSuggestionGroupPayload { + ids: string[]; + proposedFilterName?: string; + proposedFilterLabelSet?: Record; + addControlKeys?: string[]; + removeIds?: string[]; } export interface DashboardSuggestionsPreview { @@ -78,6 +124,7 @@ export interface DashboardSuggestion { uuid?: string; status: DashboardSuggestionStatus; controlId: string; + controlCatalogId?: string; controlTitle?: string; labelSetHash: string; labelSet?: Record; @@ -93,10 +140,101 @@ export interface DashboardSuggestion { targetFilterId?: string; targetFilterName?: string; evidenceCount?: number; + isUserEdited?: boolean; + editedByUserId?: string; + editedAt?: string; + // AI baseline captured at first edit, for rendering the diff on the card. + originalProposedFilterLabelSet?: Record; + originalProposedFilterName?: string; + addedByUser?: boolean; + removedControlIds?: string[]; + // Marks rows produced by the deterministic filter-merge detector. Such a row + // proposes the generalized label set that merges several near-duplicate + // filters; `sourceFilterIds` are the filters it merges. + isGeneralization?: boolean; + sourceFilterIds?: string[]; createdAt?: string; updatedAt?: string; } +export type LabelChipKind = 'unchanged' | 'added' | 'removed'; + +export interface LabelChip { + text: string; + kind: LabelChipKind; +} + +export interface GroupEditDiff { + labelChips: LabelChip[]; + titleFrom?: string; + titleTo?: string; + addedControlIds: string[]; + removedControlIds: string[]; + edited: boolean; +} + +// Computes the visual diff of a pending group versus its AI baseline. Added +// labels are green, removed labels red; a changed value shows both. Title change +// and control add/remove are surfaced for the card. +export function computeGroupEditDiff( + suggestions: DashboardSuggestion[], + currentLabels: Record, +): GroupEditDiff { + const baselineRow = suggestions.find( + (s) => + s.originalProposedFilterLabelSet && + Object.keys(s.originalProposedFilterLabelSet).length > 0, + ); + const baseline = baselineRow?.originalProposedFilterLabelSet; + const originalName = suggestions.find( + (s) => s.originalProposedFilterName, + )?.originalProposedFilterName; + const currentName = suggestions.find( + (s) => s.proposedFilterName, + )?.proposedFilterName; + const removedControlIds = + suggestions.find((s) => s.removedControlIds?.length)?.removedControlIds ?? + []; + const addedControlIds = suggestions + .filter((s) => s.addedByUser) + .map((s) => s.controlId); + const edited = suggestions.some((s) => s.isUserEdited); + + const labelChips: LabelChip[] = []; + const keys = new Set([ + ...Object.keys(currentLabels), + ...(baseline ? Object.keys(baseline) : []), + ]); + for (const key of Array.from(keys).sort()) { + const current = currentLabels[key]; + const base = baseline?.[key]; + if (!baseline) { + labelChips.push({ text: `${key}=${current}`, kind: 'unchanged' }); + } else if (current !== undefined && base === undefined) { + labelChips.push({ text: `${key}=${current}`, kind: 'added' }); + } else if (current === undefined && base !== undefined) { + labelChips.push({ text: `${key}=${base}`, kind: 'removed' }); + } else if (current !== base) { + labelChips.push({ text: `${key}=${base}`, kind: 'removed' }); + labelChips.push({ text: `${key}=${current}`, kind: 'added' }); + } else { + labelChips.push({ text: `${key}=${current}`, kind: 'unchanged' }); + } + } + + const titleChanged = + !!originalName && !!currentName && originalName !== currentName; + + return { + labelChips, + titleFrom: titleChanged ? originalName : undefined, + titleTo: titleChanged ? currentName : undefined, + addedControlIds, + removedControlIds, + edited, + }; +} + export interface DashboardSuggestionEvent { id?: string; uuid?: string; @@ -128,12 +266,59 @@ export function buildPreviewDashboardSuggestionsEndpoint( return `${dashboardSuggestionsBaseEndpoint(sspId)}/dashboard-suggestions/preview`; } +export function buildGeneralizeDashboardSuggestionsEndpoint( + sspId: string, +): string { + return `${dashboardSuggestionsBaseEndpoint(sspId)}/dashboard-suggestions/generalize`; +} + +// Result of triggering the deterministic filter-merge detector. +export interface GeneralizeDashboardSuggestionsResult { + candidates: number; + inserted: number; +} + export function buildDashboardSuggestionLabelSetsEndpoint( sspId: string, ): string { return `${dashboardSuggestionsBaseEndpoint(sspId)}/dashboard-suggestions/label-sets`; } +export function buildDashboardSuggestionLabelKeysEndpoint( + sspId: string, +): string { + return `${dashboardSuggestionsBaseEndpoint(sspId)}/dashboard-suggestions/label-keys`; +} + +export function buildDashboardSuggestionLabelValuesEndpoint( + sspId: string, +): string { + return `${dashboardSuggestionsBaseEndpoint(sspId)}/dashboard-suggestions/label-values`; +} + +// Builds a labelfilter.Filter from a list of AND-ed key=value conditions, or +// undefined when there are no conditions. Single condition collapses to a bare +// scope.condition; multiple become a scope.query with an AND operator. +export function buildLabelFilter( + conditions: { key: string; value: string }[], +): LabelFilter | undefined { + const valid = conditions.filter((c) => c.key.trim() && c.value.trim()); + if (valid.length === 0) { + return undefined; + } + const scopes = valid.map((c) => ({ + condition: { + label: c.key.trim(), + operator: '=' as const, + value: c.value.trim(), + }, + })); + if (scopes.length === 1) { + return { scope: scopes[0] }; + } + return { scope: { query: { operator: 'AND' as const, scopes } } }; +} + export function buildLatestDashboardSuggestionRunEndpoint( sspId: string, ): string { @@ -156,6 +341,12 @@ export function buildRejectDashboardSuggestionsEndpoint(sspId: string): string { return `${dashboardSuggestionsBaseEndpoint(sspId)}/dashboard-suggestions/reject`; } +export function buildEditDashboardSuggestionGroupEndpoint( + sspId: string, +): string { + return `${dashboardSuggestionsBaseEndpoint(sspId)}/dashboard-suggestions/edit-group`; +} + export function buildDashboardSuggestionEventsEndpoint( sspId: string, suggestionId: string, @@ -163,6 +354,11 @@ export function buildDashboardSuggestionEventsEndpoint( return `${dashboardSuggestionsBaseEndpoint(sspId)}/dashboard-suggestions/${encodeURIComponent(suggestionId)}/events`; } +// Builds the catalog-qualified control key the API expects (`:`). +export function buildControlKey(catalogId: string, controlId: string): string { + return `${catalogId}:${controlId}`; +} + export function formatLabelSet(labels: Record): string[] { return Object.entries(labels).map(([key, value]) => `${key}=${value}`); } From ad3b5ce805974407ea0581887e1b09687bad3d66 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Thu, 18 Jun 2026 09:34:11 -0300 Subject: [PATCH 2/2] fix: address error handling Signed-off-by: Gustavo Carvalho --- src/views/dashboard/SuggestionsView.vue | 11 ++++++++--- src/views/dashboard/partials/SuggestionEditDialog.vue | 8 ++++---- .../dashboard/partials/SuggestionScopeDialog.vue | 2 +- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/views/dashboard/SuggestionsView.vue b/src/views/dashboard/SuggestionsView.vue index c7f551f4..4ddb1db3 100644 --- a/src/views/dashboard/SuggestionsView.vue +++ b/src/views/dashboard/SuggestionsView.vue @@ -346,6 +346,7 @@ :control-options="controlOptions" :resolve-catalog-id="resolveControlCatalogId" :saving="editingGroup" + :error="editError" @save="saveGroupEdit" /> @@ -459,6 +460,7 @@ const auditEvents = ref([]); const scopeCeilingError = ref(''); const showEditDialog = ref(false); const editGroup = ref(null); +const editError = ref(''); const controlTitleById = ref(new Map()); const controlCatalogById = ref(new Map()); const controlCatalogIdById = ref(new Map()); @@ -986,8 +988,8 @@ async function suggestFilterMerges() { } } -// Builds the chip label for a generalization group from its source filter count -// and the dropped label key (parsed from the deterministic reasoning string). +// Builds the chip label for a generalization group from the number of source +// filters it merges. function generalizationLabel(group: PendingGroup) { const count = group.suggestions[0]?.sourceFilterIds?.length ?? 0; return `Merges ${count} filter${count === 1 ? '' : 's'}`; @@ -1034,16 +1036,19 @@ function resolveControlCatalogId(controlId: string) { function openEditDialog(group: PendingGroup) { editGroup.value = group; + editError.value = ''; showEditDialog.value = true; } async function saveGroupEdit(payload: EditDashboardSuggestionGroupPayload) { try { + editError.value = ''; await editSuggestionGroup(payload); selectedSuggestionIds.value = []; showEditDialog.value = false; } catch (error) { - scopeCeilingError.value = extractErrorMessage(error); + // Keep the dialog open and surface the failure inline so the user can retry. + editError.value = extractErrorMessage(error); } } diff --git a/src/views/dashboard/partials/SuggestionEditDialog.vue b/src/views/dashboard/partials/SuggestionEditDialog.vue index 6389f550..a8238ab1 100644 --- a/src/views/dashboard/partials/SuggestionEditDialog.vue +++ b/src/views/dashboard/partials/SuggestionEditDialog.vue @@ -88,8 +88,8 @@
- - {{ errorMessage }} + + {{ error }}
@@ -141,6 +141,8 @@ const props = defineProps<{ // Resolves a control's catalog UUID; falls back to the group's catalog id. resolveCatalogId?: (controlId: string) => string | undefined; saving?: boolean; + // Server-side save error, surfaced inline so the dialog stays open on failure. + error?: string; }>(); const emit = defineEmits<{ @@ -152,7 +154,6 @@ const title = ref(''); const labelRows = ref([]); const removeIds = ref([]); const addControlIds = ref([]); -const errorMessage = ref(''); const modelVisible = computed({ get: () => props.visible, @@ -214,7 +215,6 @@ watch( } removeIds.value = []; addControlIds.value = []; - errorMessage.value = ''; }, { immediate: true }, ); diff --git a/src/views/dashboard/partials/SuggestionScopeDialog.vue b/src/views/dashboard/partials/SuggestionScopeDialog.vue index 90cc081f..b87a516b 100644 --- a/src/views/dashboard/partials/SuggestionScopeDialog.vue +++ b/src/views/dashboard/partials/SuggestionScopeDialog.vue @@ -404,7 +404,7 @@ async function fetchPreview(requestId: number) { >( buildPreviewDashboardSuggestionsEndpoint(props.sspId), body, - // Backend expects snake_case keys (e.g. `control_keys`); without this the + // Backend expects kebab-case keys (e.g. `control-keys`); without this the // scope keys go out as camelCase and the API reads an empty scope (422). { transformRequest: [decamelizeKeys] }, );