diff --git a/apps/app/src/components/form-failures/FormFailuresTable.vue b/apps/app/src/components/form-failures/FormFailuresTable.vue new file mode 100644 index 00000000..c03d8270 --- /dev/null +++ b/apps/app/src/components/form-failures/FormFailuresTable.vue @@ -0,0 +1,459 @@ + + + diff --git a/apps/app/src/components/form-failures/__tests__/FormFailuresTable.spec.ts b/apps/app/src/components/form-failures/__tests__/FormFailuresTable.spec.ts new file mode 100644 index 00000000..94c1ccca --- /dev/null +++ b/apps/app/src/components/form-failures/__tests__/FormFailuresTable.spec.ts @@ -0,0 +1,179 @@ +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() +const mockPost = vi.fn() +const pushSpy = vi.fn() + +vi.mock('@/lib/axios', () => ({ + apiClient: { + get: (...args: unknown[]) => mockGet(...args), + post: (...args: unknown[]) => mockPost(...args), + }, +})) + +vi.mock('vue-router', () => ({ + useRoute: () => ({ params: {}, query: {} }), + useRouter: () => ({ push: pushSpy, replace: vi.fn() }), +})) + +import FormFailuresTable from '../FormFailuresTable.vue' +import type { FormFailure } from '@/types/form-failures' + +function makeFailure(overrides: Partial = {}): FormFailure { + return { + id: '01ABC123456789', + form_submission_id: '01SUB', + binding_id: null, + listener_class: 'App\\Listeners\\FormBuilder\\ApplyBindingsOnFormSubmit', + failed_at: '2026-04-28T12:00:00Z', + exception_class: 'RuntimeException', + exception_message: 'something broke', + context: null, + 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: '
' }, + VCardText: { template: '
' }, + VAlert: { template: '
' }, + VBtn: { + template: '', + props: ['disabled', 'loading', 'color', 'variant', 'icon', 'size'], + }, + VBtnToggle: { + template: '
', + props: ['modelValue'], + }, + VIcon: { template: '', props: ['icon', 'size', 'color'] }, + VChip: { template: '', props: ['color', 'size', 'variant'] }, + AppTextField: { + template: '', + props: ['modelValue', 'placeholder', 'prependInnerIcon', 'clearable'], + }, + VDataTableServer: { + template: `
+ + +
`, + props: ['headers', 'items', 'itemsLength', 'loading', 'itemsPerPage', 'page', 'hover'], + }, + VMenu: { + template: '
', + }, + VList: { template: '
' }, + VListItem: { template: '
{{ title }}
', props: ['title', 'prependIcon'] }, + VTooltip: { template: '' }, + RetryFailureDialog: { template: '
' }, + ResolveFailureDialog: { template: '
' }, + DismissFailureDialog: { template: '
' }, +} + +function mountTable(items: FormFailure[]) { + // Mocks for both the list and the kpi list calls (composable issues + // a single per_page=100 list that drives both). + mockGet.mockResolvedValue({ + data: { data: items, links: {}, meta: { current_page: 1, per_page: 25, total: items.length, last_page: 1 } }, + }) + + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + + return mount(FormFailuresTable, { + props: { scope: 'platform' }, + global: { + stubs, + plugins: [[VueQueryPlugin, { queryClient: client }]], + }, + }) +} + +beforeEach(() => { + mockGet.mockReset() + mockPost.mockReset() + pushSpy.mockReset() +}) + +describe('FormFailuresTable', () => { + it('renders KPI tiles with computed counts from list data', async () => { + const w = mountTable([ + makeFailure({ id: 'A', state: 'open' }), + makeFailure({ id: 'B', state: 'resolved' }), + makeFailure({ id: 'C', state: 'dismissed' }), + makeFailure({ id: 'D', state: 'open' }), + ]) + await flushPromises() + await flushPromises() + + const text = w.text() + expect(text).toContain('Open failures') + expect(text).toContain('Opgelost') + expect(text).toContain('Dismissed') + expect(text).toContain('Totaal') + }) + + it('shows the open-state chip for an open failure row', async () => { + const w = mountTable([makeFailure({ state: 'open' })]) + await flushPromises() + await flushPromises() + + const chip = w.find('.v-chip-stub') + expect(chip.exists()).toBe(true) + expect(chip.attributes('data-color')).toBe('error') + }) + + it('shows the resolved-state chip color for a resolved failure', async () => { + const w = mountTable([makeFailure({ state: 'resolved' })]) + await flushPromises() + await flushPromises() + + // stateFilter defaults to 'open'; resolved row gets filtered out. + expect(w.findAll('.v-chip-stub')).toHaveLength(0) + }) + + it('renders empty state when there are no rows', async () => { + const w = mountTable([]) + await flushPromises() + await flushPromises() + + expect(w.text()).toContain('Geen failures gevonden') + }) + + it('does not show retry button for resolved rows when state filter is "all"', async () => { + const w = mountTable([makeFailure({ state: 'resolved' })]) + await flushPromises() + await flushPromises() + + // Click "Alle" toggle button + const buttons = w.findAll('button') + const allBtn = buttons.find(b => b.text() === 'Alle') + expect(allBtn).toBeTruthy() + await allBtn?.trigger('click') + await flushPromises() + + // For resolved row, retry button is not rendered (template guards + // on item.state === 'open'). + const buttonTexts = w.findAll('button').map(b => b.text()) + expect(buttonTexts).not.toContain('Retry') + }) +}) diff --git a/apps/app/src/navigation/vertical/index.ts b/apps/app/src/navigation/vertical/index.ts index 148ede39..bd9c5e4b 100644 --- a/apps/app/src/navigation/vertical/index.ts +++ b/apps/app/src/navigation/vertical/index.ts @@ -27,6 +27,11 @@ export const orgNavItems = [ to: { name: 'organisation-companies' }, icon: { icon: 'tabler-building' }, }, + { + title: 'Form failures', + to: { name: 'organisation-form-failures' }, + icon: { icon: 'tabler-alert-triangle' }, + }, { title: 'Instellingen', to: { name: 'organisation-settings' }, @@ -53,6 +58,11 @@ export const platformNavItems = [ to: { name: 'platform-users' }, icon: { icon: 'tabler-users-group' }, }, + { + title: 'Form failures', + to: { name: 'platform-form-failures' }, + icon: { icon: 'tabler-alert-triangle' }, + }, { title: 'Activity Log', to: { name: 'platform-activity-log' }, diff --git a/apps/app/src/pages/organisation/form-failures/index.vue b/apps/app/src/pages/organisation/form-failures/index.vue new file mode 100644 index 00000000..b136f482 --- /dev/null +++ b/apps/app/src/pages/organisation/form-failures/index.vue @@ -0,0 +1,33 @@ + + + diff --git a/apps/app/src/pages/platform/form-failures/index.vue b/apps/app/src/pages/platform/form-failures/index.vue new file mode 100644 index 00000000..c0ca6d95 --- /dev/null +++ b/apps/app/src/pages/platform/form-failures/index.vue @@ -0,0 +1,24 @@ + + +