feat(form-failures): admin detail view (WS-6)

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 <pre> 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) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 21:50:36 +02:00
parent 4c80289c47
commit 786bca8cf1
4 changed files with 580 additions and 0 deletions

View File

@@ -0,0 +1,365 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useFormFailure } from '@/composables/api/useFormFailures'
import type { FormFailureScope } from '@/composables/api/useFormFailures'
import { listenerShortName, shortId } from '@/types/form-failures'
import RetryFailureDialog from '@/components/form-failures/RetryFailureDialog.vue'
import ResolveFailureDialog from '@/components/form-failures/ResolveFailureDialog.vue'
import DismissFailureDialog from '@/components/form-failures/DismissFailureDialog.vue'
const props = defineProps<{
failureId: string
scope: FormFailureScope
orgId?: string
}>()
const failureIdRef = computed(() => props.failureId)
const orgIdRef = computed(() => props.orgId)
const { data: failure, isLoading, isError, refetch } = useFormFailure(failureIdRef, props.scope, orgIdRef)
const stateLabel = { open: 'Open', resolved: 'Opgelost', dismissed: 'Dismissed' } as const
const stateColor = { open: 'error', resolved: 'success', dismissed: 'warning' } as const
function formatDateTime(iso: string | null): string {
if (!iso) return '—'
return new Date(iso).toLocaleDateString('nl-NL', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
const isOpen = computed(() => failure.value?.state === 'open')
const retryDialogOpen = ref(false)
const resolveDialogOpen = ref(false)
const dismissDialogOpen = ref(false)
function copyText(text: string): void {
if (navigator?.clipboard) {
void navigator.clipboard.writeText(text)
}
}
const formattedContext = computed(() => {
if (!failure.value?.context) return null
try {
return JSON.stringify(failure.value.context, null, 2)
}
catch {
return null
}
})
</script>
<template>
<div>
<!-- Loading -->
<div
v-if="isLoading"
class="text-center pa-8"
>
<VProgressCircular
indeterminate
color="primary"
/>
</div>
<!-- Error -->
<VAlert
v-else-if="isError"
type="error"
class="mb-4"
>
Kon failure niet laden.
<template #append>
<VBtn
variant="text"
@click="refetch()"
>
Opnieuw proberen
</VBtn>
</template>
</VAlert>
<!-- Detail body -->
<div v-else-if="failure">
<!-- Header -->
<div class="d-flex align-start justify-space-between mb-6 flex-wrap ga-3">
<div>
<div class="d-flex align-center mb-2 ga-2">
<VChip
:color="stateColor[failure.state]"
size="large"
>
{{ stateLabel[failure.state] }}
</VChip>
<h4 class="text-h4">
Form failure
<code class="text-h5">{{ shortId(failure.id) }}</code>
</h4>
</div>
<p class="text-body-2 text-disabled mb-0">
Failed {{ formatDateTime(failure.failed_at) }} · Listener: {{ listenerShortName(failure.listener_class) }}
</p>
</div>
<!-- Actions -->
<div class="d-flex ga-2">
<VBtn
color="error"
prepend-icon="tabler-refresh"
:disabled="!isOpen"
@click="retryDialogOpen = true"
>
Retry
</VBtn>
<VBtn
color="success"
prepend-icon="tabler-check"
:disabled="!isOpen"
@click="resolveDialogOpen = true"
>
Markeren als opgelost
</VBtn>
<VBtn
color="warning"
prepend-icon="tabler-x"
:disabled="!isOpen"
@click="dismissDialogOpen = true"
>
Dismiss
</VBtn>
</div>
</div>
<VRow>
<!-- Left column 60% -->
<VCol
cols="12"
md="7"
>
<!-- Exception card -->
<VCard
title="Exception"
class="mb-4"
>
<VCardText>
<div class="mb-3">
<span class="text-caption text-disabled d-block mb-1">Class</span>
<code class="text-body-2 d-block">{{ failure.exception_class }}</code>
</div>
<div>
<span class="text-caption text-disabled d-block mb-1">Message</span>
<code class="text-body-2 d-block">{{ failure.exception_message }}</code>
</div>
<VBtn
variant="text"
size="small"
prepend-icon="tabler-copy"
class="mt-2"
@click="copyText(failure.exception_message)"
>
Bericht kopiëren
</VBtn>
</VCardText>
</VCard>
<!-- Context card -->
<VCard
v-if="formattedContext"
title="Context"
class="mb-4"
>
<VCardText>
<pre class="text-body-2"
style="white-space: pre-wrap; max-height: 300px; overflow: auto;"
>{{ formattedContext }}</pre>
<VBtn
variant="text"
size="small"
prepend-icon="tabler-copy"
class="mt-2"
@click="copyText(formattedContext)"
>
Kopieer als JSON
</VBtn>
</VCardText>
</VCard>
<!-- Resolution timeline -->
<VCard
title="Tijdlijn"
class="mb-4"
>
<VCardText>
<VTimeline
density="compact"
side="end"
>
<VTimelineItem
dot-color="error"
size="small"
>
<div class="text-body-2">
<strong>Failed</strong>
<span class="text-caption text-disabled ms-2">{{ formatDateTime(failure.failed_at) }}</span>
</div>
</VTimelineItem>
<VTimelineItem
v-if="failure.retry_count > 0"
dot-color="warning"
size="small"
>
<div class="text-body-2">
<strong>{{ failure.retry_count }} retry-poging{{ failure.retry_count === 1 ? '' : 'en' }}</strong>
</div>
</VTimelineItem>
<VTimelineItem
v-if="failure.resolved_at"
dot-color="success"
size="small"
>
<div class="text-body-2">
<strong>Opgelost</strong>
<span class="text-caption text-disabled ms-2">{{ formatDateTime(failure.resolved_at) }}</span>
<p
v-if="failure.resolved_note"
class="text-caption mt-1 mb-0"
>
"{{ failure.resolved_note }}"
</p>
</div>
</VTimelineItem>
<VTimelineItem
v-if="failure.dismissed_at"
dot-color="warning"
size="small"
>
<div class="text-body-2">
<strong>Dismissed</strong> ({{ failure.dismissed_reason_type }})
<span class="text-caption text-disabled ms-2">{{ formatDateTime(failure.dismissed_at) }}</span>
<p
v-if="failure.dismissed_reason_note"
class="text-caption mt-1 mb-0"
>
"{{ failure.dismissed_reason_note }}"
</p>
</div>
</VTimelineItem>
<VTimelineItem
v-if="isOpen && failure.retry_count === 0"
dot-color="info"
size="small"
>
<div class="text-body-2 text-disabled">
In afwachting van actie...
</div>
</VTimelineItem>
</VTimeline>
</VCardText>
</VCard>
</VCol>
<!-- Right column 40% -->
<VCol
cols="12"
md="5"
>
<!-- Submission card -->
<VCard
title="Inzending"
class="mb-4"
>
<VCardText>
<div class="d-flex justify-space-between align-center mb-2">
<span class="text-caption text-disabled">Submission ID</span>
<code class="text-body-2">{{ shortId(failure.form_submission_id) }}</code>
</div>
<VBtn
variant="text"
size="small"
prepend-icon="tabler-copy"
@click="copyText(failure.form_submission_id)"
>
Volledige ID kopiëren
</VBtn>
<p class="text-caption text-disabled mt-2 mb-0">
Submission detail-pagina is nog niet beschikbaar in v1.
</p>
</VCardText>
</VCard>
<!-- Listener card -->
<VCard
title="Listener"
class="mb-4"
>
<VCardText>
<code class="text-body-2 d-block">{{ failure.listener_class }}</code>
</VCardText>
</VCard>
<!-- Retry history card -->
<VCard
title="Retry-geschiedenis"
class="mb-4"
>
<VCardText>
<p
v-if="failure.retry_count === 0"
class="text-body-2 text-disabled mb-0"
>
Nog niet opnieuw geprobeerd.
</p>
<p
v-else
class="text-body-2 mb-0"
>
<VChip
variant="outlined"
size="small"
>
{{ failure.retry_count }} pogingen
</VChip>
<span class="text-caption text-disabled ms-2">
Per-poging-detail (timestamp + outcome) is nog niet beschikbaar in v1.
</span>
</p>
</VCardText>
</VCard>
</VCol>
</VRow>
<!-- Action dialogs -->
<RetryFailureDialog
v-model="retryDialogOpen"
:failure="failure"
:scope="scope"
:org-id="orgId"
@success="refetch()"
/>
<ResolveFailureDialog
v-model="resolveDialogOpen"
:failure="failure"
:scope="scope"
:org-id="orgId"
@success="refetch()"
/>
<DismissFailureDialog
v-model="dismissDialogOpen"
:failure="failure"
:scope="scope"
:org-id="orgId"
@success="refetch()"
/>
</div>
</div>
</template>

View File

@@ -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> = {}): 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: '<div><slot/></div>' },
VCol: { template: '<div><slot/></div>' },
VCard: { template: '<div><h6 v-if="title">{{ title }}</h6><slot/></div>', props: ['title'] },
VCardText: { template: '<div><slot/></div>' },
VAlert: { template: '<div class="v-alert-stub"><slot/><slot name="append"/></div>' },
VBtn: {
template: '<button :disabled="disabled" @click="$emit(\'click\')"><slot/></button>',
props: ['disabled', 'color', 'variant', 'prependIcon', 'size'],
},
VChip: { template: '<span :data-color="color"><slot/></span>', props: ['color', 'size', 'variant'] },
VIcon: { template: '<i></i>' },
VProgressCircular: { template: '<div class="loading-stub"></div>' },
VTimeline: { template: '<div><slot/></div>' },
VTimelineItem: { template: '<div :data-color="dotColor"><slot/></div>', props: ['dotColor', 'size'] },
RetryFailureDialog: { template: '<div></div>' },
ResolveFailureDialog: { template: '<div></div>' },
DismissFailureDialog: { template: '<div></div>' },
}
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')
})
})

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import FormFailureDetail from '@/components/form-failures/FormFailureDetail.vue'
import { useOrganisationStore } from '@/stores/useOrganisationStore'
definePage({
meta: {
navActiveLink: 'organisation-form-failures',
},
})
const route = useRoute()
const failureId = computed(() => route.params.id as string)
const orgStore = useOrganisationStore()
const orgId = computed(() => orgStore.activeOrganisationId ?? '')
</script>
<template>
<FormFailureDetail
v-if="orgId"
:failure-id="failureId"
scope="org"
:org-id="orgId"
/>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import FormFailureDetail from '@/components/form-failures/FormFailureDetail.vue'
definePage({
meta: {
navActiveLink: 'platform-form-failures',
},
})
const route = useRoute()
const failureId = computed(() => route.params.id as string)
</script>
<template>
<FormFailureDetail
:failure-id="failureId"
scope="platform"
/>
</template>