From 786bca8cf14af6455717be97b384364d5d2bde67 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Tue, 28 Apr 2026 21:50:36 +0200 Subject: [PATCH] feat(form-failures): admin detail view (WS-6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FormFailureDetail shared component drives both detail pages: - apps/app/src/pages/platform/form-failures/[id].vue - apps/app/src/pages/organisation/form-failures/[id].vue Layout (per design schets): - Header with state badge (large) + title (Form failure {short-id}) + relative-time subtitle + listener short-name - Action button row (Retry / Markeren als opgelost / Dismiss), disabled for non-open states - 60/40 two-column layout via VRow/VCol(md=7/md=5) Left column: - Exception card: class + message in code blocks + "Bericht kopiëren" button (navigator.clipboard) - Context card (only when context is non-null): pretty-printed JSON in
 with copy-as-JSON button
  - Tijdlijn (VTimeline): Failed → Retry-pogingen → Opgelost or
    Dismissed → "In afwachting van actie..." for open with no retries

Right column:
  - Inzending card: form_submission_id with copy button. The
    submission detail-pagina link is documented as "nog niet
    beschikbaar in v1" inline; opening submissions in the SPA isn't
    yet implemented (forward-pointed).
  - Listener card: full FQN listener_class
  - Retry-geschiedenis card: count chip + caveat that per-attempt
    detail (timestamp + outcome) is not yet shipped by the backend
    resource (the FormSubmissionActionFailureResource ships only
    retry_count, not a retry history array)

Action dialogs reused from Task 2; refetch on success.

8 Vitest tests cover loading state, header rendering, all 6 cards
present, action button disabled-ness per state (open/resolved/
dismissed), and timeline content for resolved + open-no-retries
states.

Refs: WS-6 sessie 3b admin UI Task 4

Co-Authored-By: Claude Opus 4.7 (1M context) 
---
 .../form-failures/FormFailureDetail.vue       | 365 ++++++++++++++++++
 .../__tests__/FormFailureDetail.spec.ts       | 168 ++++++++
 .../pages/organisation/form-failures/[id].vue |  26 ++
 .../src/pages/platform/form-failures/[id].vue |  21 +
 4 files changed, 580 insertions(+)
 create mode 100644 apps/app/src/components/form-failures/FormFailureDetail.vue
 create mode 100644 apps/app/src/components/form-failures/__tests__/FormFailureDetail.spec.ts
 create mode 100644 apps/app/src/pages/organisation/form-failures/[id].vue
 create mode 100644 apps/app/src/pages/platform/form-failures/[id].vue

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 @@ + + +