diff --git a/apps/app/src/components/form-failures/FormFailureDetail.vue b/apps/app/src/components/form-failures/FormFailureDetail.vue new file mode 100644 index 00000000..5bbf2c4e --- /dev/null +++ b/apps/app/src/components/form-failures/FormFailureDetail.vue @@ -0,0 +1,365 @@ + + + diff --git a/apps/app/src/components/form-failures/__tests__/FormFailureDetail.spec.ts b/apps/app/src/components/form-failures/__tests__/FormFailureDetail.spec.ts new file mode 100644 index 00000000..7a27e01a --- /dev/null +++ b/apps/app/src/components/form-failures/__tests__/FormFailureDetail.spec.ts @@ -0,0 +1,168 @@ +import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query' +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockGet = vi.fn() +vi.mock('@/lib/axios', () => ({ + apiClient: { + get: (...args: unknown[]) => mockGet(...args), + post: vi.fn(), + }, +})) + +import FormFailureDetail from '../FormFailureDetail.vue' +import type { FormFailure } from '@/types/form-failures' + +function makeFailure(overrides: Partial = {}): FormFailure { + return { + id: '01ABCDEFGHIJ', + form_submission_id: '01SUBMISSION', + binding_id: null, + listener_class: 'App\\Listeners\\FormBuilder\\ApplyBindingsOnFormSubmit', + failed_at: '2026-04-28T12:00:00Z', + exception_class: 'RuntimeException', + exception_message: 'something broke', + context: { target_entity: 'person', target_attribute: 'email' }, + retry_count: 0, + resolved_at: null, + resolved_note: null, + dismissed_at: null, + dismissed_reason_type: null, + dismissed_reason_note: null, + state: 'open', + ...overrides, + } +} + +const stubs = { + VRow: { template: '
' }, + VCol: { template: '
' }, + VCard: { template: '
{{ title }}
', props: ['title'] }, + VCardText: { template: '
' }, + VAlert: { template: '
' }, + VBtn: { + template: '', + props: ['disabled', 'color', 'variant', 'prependIcon', 'size'], + }, + VChip: { template: '', props: ['color', 'size', 'variant'] }, + VIcon: { template: '' }, + VProgressCircular: { template: '
' }, + VTimeline: { template: '
' }, + VTimelineItem: { template: '
', props: ['dotColor', 'size'] }, + RetryFailureDialog: { template: '
' }, + ResolveFailureDialog: { template: '
' }, + DismissFailureDialog: { template: '
' }, +} + +function mountDetail(failure: FormFailure | null) { + if (failure) { + mockGet.mockResolvedValue({ data: { data: failure } }) + } + else { + // Pending Promise — but resolved silently in afterEach so the test + // runner doesn't hit a hookTimeout waiting for cleanup. + mockGet.mockResolvedValue({ data: { data: null } }) + } + + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + + return mount(FormFailureDetail, { + props: { failureId: '01ABCDEFGHIJ', scope: 'platform' }, + global: { + stubs, + plugins: [[VueQueryPlugin, { queryClient: client }]], + }, + }) +} + +beforeEach(() => mockGet.mockReset()) + +describe('FormFailureDetail', () => { + it('renders loading state initially', () => { + const w = mountDetail(null) + + expect(w.find('.loading-stub').exists()).toBe(true) + }) + + it('renders header with state badge for open failure', async () => { + const w = mountDetail(makeFailure({ state: 'open' })) + await flushPromises() + await flushPromises() + + expect(w.text()).toContain('Form failure') + expect(w.text()).toContain('01ABCDEF') // shortId + + // Open state badge + const chips = w.findAll('span[data-color]') + const stateChip = chips.find(c => c.attributes('data-color') === 'error') + expect(stateChip).toBeTruthy() + }) + + it('renders all 6 cards: Exception, Context, Tijdlijn, Inzending, Listener, Retry-geschiedenis', async () => { + const w = mountDetail(makeFailure()) + await flushPromises() + await flushPromises() + + const text = w.text() + expect(text).toContain('Exception') + expect(text).toContain('Context') + expect(text).toContain('Tijdlijn') + expect(text).toContain('Inzending') + expect(text).toContain('Listener') + expect(text).toContain('Retry-geschiedenis') + }) + + it('disables action buttons for resolved state', async () => { + const w = mountDetail(makeFailure({ state: 'resolved', resolved_at: '2026-04-29T10:00:00Z' })) + await flushPromises() + await flushPromises() + + const buttons = w.findAll('button') + const retry = buttons.find(b => b.text() === 'Retry') + const resolve = buttons.find(b => b.text() === 'Markeren als opgelost') + const dismiss = buttons.find(b => b.text() === 'Dismiss') + + expect((retry?.element as HTMLButtonElement).disabled).toBe(true) + expect((resolve?.element as HTMLButtonElement).disabled).toBe(true) + expect((dismiss?.element as HTMLButtonElement).disabled).toBe(true) + }) + + it('disables action buttons for dismissed state', async () => { + const w = mountDetail(makeFailure({ state: 'dismissed', dismissed_at: '2026-04-29T10:00:00Z', dismissed_reason_type: 'schema_deleted' })) + await flushPromises() + await flushPromises() + + const retry = w.findAll('button').find(b => b.text() === 'Retry') + expect((retry?.element as HTMLButtonElement).disabled).toBe(true) + }) + + it('enables action buttons for open state', async () => { + const w = mountDetail(makeFailure({ state: 'open' })) + await flushPromises() + await flushPromises() + + const retry = w.findAll('button').find(b => b.text() === 'Retry') + expect((retry?.element as HTMLButtonElement).disabled).toBe(false) + }) + + it('renders "In afwachting van actie..." for open with no retries', async () => { + const w = mountDetail(makeFailure({ state: 'open', retry_count: 0 })) + await flushPromises() + await flushPromises() + + expect(w.text()).toContain('In afwachting van actie') + }) + + it('renders resolved-by entry for resolved failure', async () => { + const w = mountDetail(makeFailure({ + state: 'resolved', + resolved_at: '2026-04-29T10:00:00Z', + resolved_note: 'fixed via direct edit', + })) + await flushPromises() + await flushPromises() + + expect(w.text()).toContain('Opgelost') + expect(w.text()).toContain('fixed via direct edit') + }) +}) diff --git a/apps/app/src/pages/organisation/form-failures/[id].vue b/apps/app/src/pages/organisation/form-failures/[id].vue new file mode 100644 index 00000000..5f54f8a2 --- /dev/null +++ b/apps/app/src/pages/organisation/form-failures/[id].vue @@ -0,0 +1,26 @@ + + + diff --git a/apps/app/src/pages/platform/form-failures/[id].vue b/apps/app/src/pages/platform/form-failures/[id].vue new file mode 100644 index 00000000..dbdb31c1 --- /dev/null +++ b/apps/app/src/pages/platform/form-failures/[id].vue @@ -0,0 +1,21 @@ + + +