From c78416ae4613917ed1a44a4caecf40deaf307a8c Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Mon, 5 Oct 2026 07:40:53 -0300 Subject: [PATCH 1/2] feat(agent-config): pending changes bar, review-and-save and raw overlay dialogs Layer 14 of 21 in the stacked split of compliance-framework/ui#318. Co-Authored-By: Claude Opus 5.5 --- .../config/__tests__/RawOverlayDialog.spec.ts | 122 +++++++ .../config/workspace/PendingChangesBar.vue | 196 ++++++++++ .../config/workspace/RawOverlayDialog.vue | 269 ++++++++++++++ .../config/workspace/ReviewSaveDialog.vue | 341 ++++++++++++++++++ 4 files changed, 928 insertions(+) create mode 100644 src/components/agents/config/__tests__/RawOverlayDialog.spec.ts create mode 100644 src/components/agents/config/workspace/PendingChangesBar.vue create mode 100644 src/components/agents/config/workspace/RawOverlayDialog.vue create mode 100644 src/components/agents/config/workspace/ReviewSaveDialog.vue diff --git a/src/components/agents/config/__tests__/RawOverlayDialog.spec.ts b/src/components/agents/config/__tests__/RawOverlayDialog.spec.ts new file mode 100644 index 00000000..5b755593 --- /dev/null +++ b/src/components/agents/config/__tests__/RawOverlayDialog.spec.ts @@ -0,0 +1,122 @@ +// R70: the raw overlay YAML dialog feeds the shared draft; forbidden keys (R71) are +// highlighted and block Apply; Clear overlay needs agent:configure. +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { flushPromises, mount } from '@vue/test-utils'; +import { resetAgentDrafts } from '@/composables/agent-config/draftRegistry'; +import type { ConfigWorkspace } from '@/composables/agent-config/useConfigWorkspace'; +import { + ADMIN, + READER, + fakeApi, + globalWith, + piniaWith, + workspaceHost, +} from './helpers'; + +vi.mock('@/components/code-editor', () => import('./codeEditorMock')); + +import RawOverlayDialog from '../workspace/RawOverlayDialog.vue'; + +async function mountDialog( + perms: Record = ADMIN, + over: Parameters[0] = {}, +) { + const out: { ws?: ConfigWorkspace } = {}; + const wrapper = mount( + workspaceHost( + fakeApi(over), + RawOverlayDialog, + () => ({ visible: true }), + out, + ), + { global: globalWith(piniaWith(perms), { teleport: true }) }, + ); + await flushPromises(); + await out.ws!.loadDetails(); + await flushPromises(); + return { wrapper, ws: out.ws! }; +} + +async function type(wrapper: ReturnType, text: string) { + vi.useFakeTimers(); + await wrapper.find('textarea').setValue(text); + vi.advanceTimersByTime(300); + vi.useRealTimers(); + await flushPromises(); +} + +describe('RawOverlayDialog (R70)', () => { + beforeEach(() => resetAgentDrafts()); + + it('starts from the draft and applies a parsed, coerced document to it', async () => { + const { wrapper, ws } = await mountDialog(); + expect( + (wrapper.find('textarea').element as HTMLTextAreaElement).value, + ).toContain('local-ssh-policies:v1.1.0'); + expect( + wrapper.find('[data-test="raw-apply"]').attributes('disabled'), + ).toBeDefined(); + await type( + wrapper, + 'verbosity: 2\nplugins:\n local-ssh:\n config:\n debug: true\n', + ); + // Only booleans are coerced (R27); a number would be a blocking issue instead. + expect(wrapper.find('[data-test="yaml-coerced"]').text()).toContain( + '/plugins/local-ssh/config/debug', + ); + await wrapper.find('[data-test="raw-apply"]').trigger('click'); + expect(ws.draft.overlay.value).toEqual({ + verbosity: 2, + plugins: { 'local-ssh': { config: { debug: 'true' } } }, + }); + expect( + wrapper.findComponent(RawOverlayDialog).emitted('update:visible'), + ).toEqual([[false]]); + }); + + it('highlights forbidden keys and blocks Apply (R71)', async () => { + const { wrapper, ws } = await mountDialog(); + await type( + wrapper, + 'verbosity: 1\ndaemon: false\nremote_config:\n mode: apply_all\n', + ); + expect(wrapper.find('[data-test="yaml-forbidden"]').text()).toContain( + 'daemon', + ); + expect(wrapper.find('[data-test="yaml-forbidden"]').text()).toContain( + 'can never be changed remotely', + ); + const diags = JSON.parse( + wrapper.find('textarea').attributes('data-diagnostics') ?? '[]', + ) as { row: number }[]; + expect(diags.map((d) => d.row)).toEqual([2, 3]); + expect( + wrapper.find('[data-test="raw-apply"]').attributes('disabled'), + ).toBeDefined(); + expect(ws.draft.isDirty.value).toBe(false); + }); + + it('shows YAML errors and keeps Apply disabled', async () => { + const { wrapper } = await mountDialog(); + await type(wrapper, 'plugins: [unclosed\n'); + expect(wrapper.find('[data-test="yaml-error"]').exists()).toBe(true); + expect( + wrapper.find('[data-test="raw-apply"]').attributes('disabled'), + ).toBeDefined(); + }); + + it('Clear overlay replaces the draft with {} for configure', async () => { + const admin = await mountDialog(); + await admin.wrapper.find('[data-test="clear-overlay"]').trigger('click'); + await flushPromises(); + await admin.wrapper.find('[data-test="raw-apply"]').trigger('click'); + expect(admin.ws.draft.overlay.value).toEqual({}); + }); + + it('a reader cannot clear', async () => { + const { wrapper } = await mountDialog(READER); + expect( + wrapper.find('[data-test="clear-overlay"]').attributes('disabled'), + ).toBeDefined(); + }); +}); diff --git a/src/components/agents/config/workspace/PendingChangesBar.vue b/src/components/agents/config/workspace/PendingChangesBar.vue new file mode 100644 index 00000000..a5dfa31b --- /dev/null +++ b/src/components/agents/config/workspace/PendingChangesBar.vue @@ -0,0 +1,196 @@ + + + diff --git a/src/components/agents/config/workspace/RawOverlayDialog.vue b/src/components/agents/config/workspace/RawOverlayDialog.vue new file mode 100644 index 00000000..099061ae --- /dev/null +++ b/src/components/agents/config/workspace/RawOverlayDialog.vue @@ -0,0 +1,269 @@ + + + diff --git a/src/components/agents/config/workspace/ReviewSaveDialog.vue b/src/components/agents/config/workspace/ReviewSaveDialog.vue new file mode 100644 index 00000000..54282844 --- /dev/null +++ b/src/components/agents/config/workspace/ReviewSaveDialog.vue @@ -0,0 +1,341 @@ + + + From 807a4ee33e70f0fff99112cb98b0a3f1768d8294 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 06:34:37 -0300 Subject: [PATCH 2/2] fix(agent-config): raw overlay dialog asks before discarding edits Escape no longer closes the dialog (inside the editor it only leaves the editor), and Cancel or x ask before discarding text that differs from what the dialog opened with: the tab unmounts the dialog on close, so the edits would be lost. Co-Authored-By: Claude Opus 5.5 --- .../config/__tests__/RawOverlayDialog.spec.ts | 44 ++++++++++++++++++- .../config/workspace/RawOverlayDialog.vue | 39 +++++++++++++--- 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/src/components/agents/config/__tests__/RawOverlayDialog.spec.ts b/src/components/agents/config/__tests__/RawOverlayDialog.spec.ts index 5b755593..3628022b 100644 --- a/src/components/agents/config/__tests__/RawOverlayDialog.spec.ts +++ b/src/components/agents/config/__tests__/RawOverlayDialog.spec.ts @@ -15,6 +15,12 @@ import { vi.mock('@/components/code-editor', () => import('./codeEditorMock')); +const confirmRequire = vi.fn(); +vi.mock('primevue/useconfirm', () => ({ + useConfirm: () => ({ require: confirmRequire }), +})); + +import PrimeDialog from 'primevue/dialog'; import RawOverlayDialog from '../workspace/RawOverlayDialog.vue'; async function mountDialog( @@ -46,7 +52,43 @@ async function type(wrapper: ReturnType, text: string) { } describe('RawOverlayDialog (R70)', () => { - beforeEach(() => resetAgentDrafts()); + beforeEach(() => { + resetAgentDrafts(); + confirmRequire.mockReset(); + }); + + it('never closes on Escape', async () => { + const { wrapper } = await mountDialog(); + // The innermost Dialog is PrimeVue's (the first is the Volt wrapper). + const prime = wrapper.findAllComponents(PrimeDialog).at(-1)!; + expect(prime.props('closeOnEscape')).toBe(false); + }); + + it('Cancel closes at once while nothing was edited', async () => { + const { wrapper } = await mountDialog(); + await wrapper.find('[data-test="raw-cancel"]').trigger('click'); + expect(confirmRequire).not.toHaveBeenCalled(); + expect( + wrapper.findComponent(RawOverlayDialog).emitted('update:visible'), + ).toEqual([[false]]); + }); + + it('Cancel and × ask before discarding edits', async () => { + const { wrapper } = await mountDialog(); + await type(wrapper, 'verbosity: 3\n'); + const dialog = wrapper.findComponent(RawOverlayDialog); + await wrapper.find('[data-test="raw-cancel"]').trigger('click'); + // The × emits update:visible false from the Dialog. + wrapper.findComponent({ name: 'Dialog' }).vm.$emit('update:visible', false); + expect(confirmRequire).toHaveBeenCalledTimes(2); + expect(dialog.emitted('update:visible')).toBeUndefined(); + expect(confirmRequire.mock.calls[0][0]).toMatchObject({ + header: 'Discard overlay edits?', + }); + // Keep editing (reject) leaves the dialog open; Discard (accept) closes it. + confirmRequire.mock.calls[0][0].accept(); + expect(dialog.emitted('update:visible')).toEqual([[false]]); + }); it('starts from the draft and applies a parsed, coerced document to it', async () => { const { wrapper, ws } = await mountDialog(); diff --git a/src/components/agents/config/workspace/RawOverlayDialog.vue b/src/components/agents/config/workspace/RawOverlayDialog.vue index 099061ae..0eb8295d 100644 --- a/src/components/agents/config/workspace/RawOverlayDialog.vue +++ b/src/components/agents/config/workspace/RawOverlayDialog.vue @@ -5,7 +5,10 @@ header="Advanced: edit raw overlay (YAML)" class="w-full max-w-4xl" data-test="raw-overlay-dialog" - @update:visible="$emit('update:visible', $event)" + :close-on-escape="false" + @update:visible=" + (v: boolean) => (v ? $emit('update:visible', v) : requestClose()) + " >

@@ -90,9 +93,7 @@ - Cancel (null); const coerced = ref([]); const parseError = ref(null); @@ -169,11 +175,34 @@ watch( (v) => { if (!v) return; text.value = toYaml(ws.draft.overlay.value); + openedWith.value = text.value; parseNow(text.value); }, { immediate: true }, ); +const confirm = useConfirm(); + +/** Cancel / ×: closes, after confirming when the text was edited. */ +function requestClose() { + if (text.value === openedWith.value) { + emit('update:visible', false); + return; + } + confirm.require({ + header: 'Discard overlay edits?', + message: + 'Your edits to the overlay YAML have not been applied to the pending changes and will be lost.', + rejectProps: { + label: 'Keep editing', + severity: 'secondary', + outlined: true, + }, + acceptProps: { label: 'Discard', severity: 'danger' }, + accept: () => emit('update:visible', false), + }); +} + function onInput(value: string) { text.value = value; if (timer) clearTimeout(timer); @@ -265,5 +294,5 @@ function clearOverlay() { parseNow(text.value); } -defineExpose({ text, onInput, flush, apply }); +defineExpose({ text, onInput, flush, apply, requestClose });