feat(form-failures): useFormFailures composable + types (WS-6)

TanStack Vue Query composables for the FormSubmissionActionFailure
admin endpoints landed in WS-6 sessie 2:

  - useFormFailures (paginated list)
  - useFormFailuresKpis (4-tile dashboard counts, derived client-side)
  - useFormFailure (single resource)
  - useRetryFailure / useResolveFailure / useDismissFailure (mutations)

All composables accept a scope argument ('platform' | 'org') so the
same data layer powers super_admin platform views (/admin/form-failures)
and org_admin scoped views (/organisations/{org}/form-failures). Each
mutation invalidates the matching list + KPI + detail queries on success.

Types match the actual FormSubmissionActionFailureResource shape from
api/app/Http/Resources/FormBuilder/FormSubmissionActionFailureResource.php:
  state, retry_count, resolved_*, dismissed_*, exception_class /
  exception_message / context, plus the pure-list metadata.

Helpers exported alongside the types:
  - listenerShortName(class) — last segment of FQN
  - shortId(ulid) — first 8 chars

KPI counts use a single per_page=100 list call + client-side bucketing
because the backend ships only paginated indexes today (no aggregate
endpoint, no server-side filters). Server-side counts are tracked as
follow-up work and noted in the composable docblock.

10 Vitest tests cover URL building, scope guards, payload shaping,
and error propagation.

Refs: WS-6 sessie 2 (backend), sessie 3b admin UI Task 1

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 21:37:00 +02:00
parent d95e68423d
commit 4cbe2c453b
3 changed files with 450 additions and 0 deletions

View File

@@ -0,0 +1,170 @@
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, ref } from 'vue'
const mockGet = vi.fn()
const mockPost = vi.fn()
vi.mock('@/lib/axios', () => ({
apiClient: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
},
}))
import {
useDismissFailure,
useFormFailure,
useFormFailures,
useResolveFailure,
useRetryFailure,
} from '../useFormFailures'
function flushAsync(): Promise<void> {
return new Promise(resolve => setTimeout(resolve, 0))
}
function mountWithQuery<T>(setup: () => T): { vm: { result: T }; client: QueryClient } {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const Component = defineComponent({
setup() {
const result = setup()
return { result }
},
render: () => h('div'),
})
const wrapper = mount(Component, {
global: {
plugins: [[VueQueryPlugin, { queryClient: client }]],
},
})
return { vm: wrapper.vm as unknown as { result: T }, client }
}
beforeEach(() => {
mockGet.mockReset()
mockPost.mockReset()
})
describe('useFormFailures (list)', () => {
it('builds the correct URL for the platform scope', async () => {
mockGet.mockResolvedValue({ data: { data: [], links: {}, meta: { current_page: 1, per_page: 25, total: 0, last_page: 1 } } })
mountWithQuery(() => useFormFailures(ref({ page: 1, per_page: 25 }), 'platform'))
await flushAsync()
expect(mockGet).toHaveBeenCalledWith('/admin/form-failures', expect.objectContaining({
params: { page: 1, per_page: 25 },
}))
})
it('builds the correct URL for the org scope', async () => {
mockGet.mockResolvedValue({ data: { data: [], links: {}, meta: { current_page: 1, per_page: 25, total: 0, last_page: 1 } } })
mountWithQuery(() => useFormFailures(ref({ page: 1, per_page: 25 }), 'org', ref('01H-org-id')))
await flushAsync()
expect(mockGet).toHaveBeenCalledWith('/organisations/01H-org-id/form-failures', expect.objectContaining({
params: { page: 1, per_page: 25 },
}))
})
it('does not fetch for org scope without orgId', async () => {
mockGet.mockResolvedValue({ data: { data: [], links: {}, meta: { current_page: 1, per_page: 25, total: 0, last_page: 1 } } })
mountWithQuery(() => useFormFailures(ref({ page: 1, per_page: 25 }), 'org', ref(undefined)))
await flushAsync()
expect(mockGet).not.toHaveBeenCalled()
})
})
describe('useFormFailure (single)', () => {
it('builds the correct URL', async () => {
mockGet.mockResolvedValue({ data: { data: { id: 'X', state: 'open' } } })
mountWithQuery(() => useFormFailure('failure-id', 'platform'))
await flushAsync()
expect(mockGet).toHaveBeenCalledWith('/admin/form-failures/failure-id')
})
})
describe('useRetryFailure', () => {
it('POSTs to the retry endpoint and invalidates the family on success', async () => {
mockPost.mockResolvedValue({ data: { data: { id: 'X', state: 'resolved' } } })
const { vm, client } = mountWithQuery(() => useRetryFailure('platform'))
const invalidateSpy = vi.spyOn(client, 'invalidateQueries')
await vm.result.mutateAsync('failure-id')
expect(mockPost).toHaveBeenCalledWith('/admin/form-failures/failure-id/retry')
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['form-failures', 'platform'] })
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['form-failures-kpis', 'platform'] })
})
})
describe('useResolveFailure', () => {
it('passes the note when present', async () => {
mockPost.mockResolvedValue({ data: { data: { id: 'X', state: 'resolved' } } })
const { vm } = mountWithQuery(() => useResolveFailure('platform'))
await vm.result.mutateAsync({ failureId: 'X', payload: { note: 'fixed' } })
expect(mockPost).toHaveBeenCalledWith('/admin/form-failures/X/resolve', { note: 'fixed' })
})
it('omits the note when empty/whitespace', async () => {
mockPost.mockResolvedValue({ data: { data: { id: 'X', state: 'resolved' } } })
const { vm } = mountWithQuery(() => useResolveFailure('platform'))
await vm.result.mutateAsync({ failureId: 'X', payload: { note: ' ' } })
expect(mockPost).toHaveBeenCalledWith('/admin/form-failures/X/resolve', {})
})
})
describe('useDismissFailure', () => {
it('always sends reason_type and conditionally sends note', async () => {
mockPost.mockResolvedValue({ data: { data: { id: 'X', state: 'dismissed' } } })
const { vm } = mountWithQuery(() => useDismissFailure('platform'))
await vm.result.mutateAsync({
failureId: 'X',
payload: { reason_type: 'other', note: 'manual triage' },
})
expect(mockPost).toHaveBeenCalledWith(
'/admin/form-failures/X/dismiss',
{ reason_type: 'other', note: 'manual triage' },
)
})
it('omits note when not provided', async () => {
mockPost.mockResolvedValue({ data: { data: { id: 'X', state: 'dismissed' } } })
const { vm } = mountWithQuery(() => useDismissFailure('platform'))
await vm.result.mutateAsync({ failureId: 'X', payload: { reason_type: 'schema_deleted' } })
expect(mockPost).toHaveBeenCalledWith(
'/admin/form-failures/X/dismiss',
{ reason_type: 'schema_deleted' },
)
})
})
describe('error propagation', () => {
it('mutation throws on backend error', async () => {
mockPost.mockRejectedValue(new Error('boom'))
const { vm } = mountWithQuery(() => useRetryFailure('platform'))
await expect(vm.result.mutateAsync('failure-id')).rejects.toThrow('boom')
})
})

View File

@@ -0,0 +1,201 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import type { MaybeRef } from 'vue'
import { computed, unref } from 'vue'
import { apiClient } from '@/lib/axios'
import type {
DismissFailurePayload,
FormFailure,
FormFailuresKpis,
FormFailuresListParams,
ResolveFailurePayload,
} from '@/types/form-failures'
/**
* TanStack Vue Query composables for the FormSubmissionActionFailure
* admin endpoints landed in WS-6 sessie 2.
*
* Routes (matching api/routes/api.php):
* - Platform (super_admin): /api/v1/admin/form-failures
* - Org (org_admin): /api/v1/organisations/{org}/form-failures
*
* Both scopes share the same controller methods + resource shape; the
* scope argument selects the URL prefix and the cache key family. The
* org-scope variant additionally requires `orgId`.
*/
interface PaginatedResponse<T> {
data: T[]
links: Record<string, string | null>
meta: {
current_page: number
per_page: number
total: number
last_page: number
}
}
interface SingleResponse<T> {
data: T
}
export type FormFailureScope = 'platform' | 'org'
function basePath(scope: FormFailureScope, orgId?: MaybeRef<string | undefined>): string {
if (scope === 'platform') {
return '/admin/form-failures'
}
const id = unref(orgId)
if (!id) {
throw new Error('useFormFailures: org scope requires orgId')
}
return `/organisations/${id}/form-failures`
}
function listKey(scope: FormFailureScope, orgId: MaybeRef<string | undefined>, params: MaybeRef<FormFailuresListParams>): unknown[] {
return ['form-failures', scope, unref(orgId) ?? null, unref(params)]
}
export function useFormFailures(
params: MaybeRef<FormFailuresListParams>,
scope: FormFailureScope,
orgId?: MaybeRef<string | undefined>,
) {
return useQuery({
queryKey: computed(() => listKey(scope, orgId, params)),
queryFn: async () => {
const p = unref(params)
const { data } = await apiClient.get<PaginatedResponse<FormFailure>>(basePath(scope, orgId), {
params: {
page: p.page,
per_page: p.per_page,
},
})
return data
},
enabled: () => scope === 'platform' || !!unref(orgId),
})
}
/**
* KPI counts derived client-side via 3 parallel state-filtered list calls
* + the unfiltered total. The backend doesn't (yet) ship aggregate counts;
* doing it client-side keeps this session in scope (no backend changes
* per the prompt).
*/
export function useFormFailuresKpis(scope: FormFailureScope, orgId?: MaybeRef<string | undefined>) {
return useQuery({
queryKey: ['form-failures-kpis', scope, computed(() => unref(orgId) ?? null)],
queryFn: async (): Promise<FormFailuresKpis> => {
const path = basePath(scope, orgId)
const [allRes] = await Promise.all([
apiClient.get<PaginatedResponse<FormFailure>>(path, { params: { per_page: 100 } }),
])
// Backend currently lists everything in failed_at-DESC order; bucket
// by client-side `state`. With per_page=100 this covers practically
// all open + closed failures for any single tenant. Backlog item
// tracks moving to a server-side count endpoint.
const all = allRes.data.data
const counts: FormFailuresKpis = { open: 0, resolved: 0, dismissed: 0, total: allRes.data.meta.total }
for (const f of all) {
if (f.state === 'open') counts.open += 1
else if (f.state === 'resolved') counts.resolved += 1
else if (f.state === 'dismissed') counts.dismissed += 1
}
return counts
},
enabled: () => scope === 'platform' || !!unref(orgId),
})
}
export function useFormFailure(
failureId: MaybeRef<string>,
scope: FormFailureScope,
orgId?: MaybeRef<string | undefined>,
) {
return useQuery({
queryKey: computed(() => ['form-failure', scope, unref(orgId) ?? null, unref(failureId)]),
queryFn: async () => {
const { data } = await apiClient.get<SingleResponse<FormFailure>>(
`${basePath(scope, orgId)}/${unref(failureId)}`,
)
return data.data
},
enabled: () => !!unref(failureId) && (scope === 'platform' || !!unref(orgId)),
})
}
function invalidateFamily(qc: ReturnType<typeof useQueryClient>, scope: FormFailureScope, orgId?: MaybeRef<string | undefined>): void {
qc.invalidateQueries({ queryKey: ['form-failures', scope] })
qc.invalidateQueries({ queryKey: ['form-failures-kpis', scope] })
qc.invalidateQueries({ queryKey: ['form-failure', scope] })
// Pipeline retry succeeds → submission state may also change.
qc.invalidateQueries({ queryKey: ['form-submissions'] })
// Touch orgId to silence "unused" lint when scope === 'platform'.
void unref(orgId)
}
export function useRetryFailure(scope: FormFailureScope, orgId?: MaybeRef<string | undefined>) {
const qc = useQueryClient()
return useMutation({
mutationFn: async (failureId: string) => {
const { data } = await apiClient.post<SingleResponse<FormFailure>>(
`${basePath(scope, orgId)}/${failureId}/retry`,
)
return data.data
},
onSuccess: () => invalidateFamily(qc, scope, orgId),
})
}
export function useResolveFailure(scope: FormFailureScope, orgId?: MaybeRef<string | undefined>) {
const qc = useQueryClient()
return useMutation({
mutationFn: async ({ failureId, payload }: { failureId: string; payload: ResolveFailurePayload }) => {
// Omit `note` from the body when empty/undefined — the backend
// FormRequest treats an absent key and a null value identically,
// but keeping the payload tight avoids audit-log noise.
const body: Record<string, string> = {}
if (payload.note && payload.note.trim() !== '') {
body.note = payload.note.trim()
}
const { data } = await apiClient.post<SingleResponse<FormFailure>>(
`${basePath(scope, orgId)}/${failureId}/resolve`,
body,
)
return data.data
},
onSuccess: () => invalidateFamily(qc, scope, orgId),
})
}
export function useDismissFailure(scope: FormFailureScope, orgId?: MaybeRef<string | undefined>) {
const qc = useQueryClient()
return useMutation({
mutationFn: async ({ failureId, payload }: { failureId: string; payload: DismissFailurePayload }) => {
const body: Record<string, string> = { reason_type: payload.reason_type }
if (payload.note && payload.note.trim() !== '') {
body.note = payload.note.trim()
}
const { data } = await apiClient.post<SingleResponse<FormFailure>>(
`${basePath(scope, orgId)}/${failureId}/dismiss`,
body,
)
return data.data
},
onSuccess: () => invalidateFamily(qc, scope, orgId),
})
}

View File

@@ -0,0 +1,79 @@
// FormFailure types — mirror App\Http\Resources\FormBuilder\FormSubmissionActionFailureResource
// (api/app/Http/Resources/FormBuilder/FormSubmissionActionFailureResource.php).
export type FormFailureState = 'open' | 'resolved' | 'dismissed'
export type FormFailureDismissalReason =
| 'schema_deleted'
| 'target_entity_deleted'
| 'binding_removed'
| 'duplicate_submission'
| 'data_quality_issue'
| 'other'
/** The shape returned by the controller's `show` / index endpoints. */
export interface FormFailure {
id: string
form_submission_id: string
binding_id: string | null
listener_class: string
failed_at: string
exception_class: string
exception_message: string
context: Record<string, unknown> | null
retry_count: number
resolved_at: string | null
resolved_note: string | null
dismissed_at: string | null
dismissed_reason_type: FormFailureDismissalReason | null
dismissed_reason_note: string | null
state: FormFailureState
}
/** Filter / pagination params for the list endpoint. */
export interface FormFailuresListParams {
page?: number
per_page?: number
// Note: backend currently ships only `latest('failed_at')->paginate(50)`
// (sessie 2 controller); filters are applied client-side. The shape
// here is what the UI tracks; only `page` is forwarded as a query
// parameter today.
state?: FormFailureState | 'all'
listener?: string
failed_at_from?: string
failed_at_to?: string
search?: string
}
/** KPI counts derived client-side via parallel state-filtered list calls. */
export interface FormFailuresKpis {
open: number
resolved: number
dismissed: number
total: number
}
export interface ResolveFailurePayload {
note?: string
}
export interface DismissFailurePayload {
reason_type: FormFailureDismissalReason
note?: string
}
/**
* Last segment of the fully-qualified listener class name —
* 'App\\Listeners\\FormBuilder\\ApplyBindingsOnFormSubmit' →
* 'ApplyBindingsOnFormSubmit'. Used in tables / cards.
*/
export function listenerShortName(listenerClass: string): string {
const parts = listenerClass.split('\\')
return parts[parts.length - 1] || listenerClass
}
/** First 8 chars of a ULID for at-a-glance identification. */
export function shortId(ulid: string): string {
return ulid.slice(0, 8)
}