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:
170
apps/app/src/composables/api/__tests__/useFormFailures.spec.ts
Normal file
170
apps/app/src/composables/api/__tests__/useFormFailures.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
201
apps/app/src/composables/api/useFormFailures.ts
Normal file
201
apps/app/src/composables/api/useFormFailures.ts
Normal 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),
|
||||
})
|
||||
}
|
||||
79
apps/app/src/types/form-failures.ts
Normal file
79
apps/app/src/types/form-failures.ts
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user