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:
365
apps/app/src/components/form-failures/FormFailureDetail.vue
Normal file
365
apps/app/src/components/form-failures/FormFailureDetail.vue
Normal 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>
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
26
apps/app/src/pages/organisation/form-failures/[id].vue
Normal file
26
apps/app/src/pages/organisation/form-failures/[id].vue
Normal 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>
|
||||
21
apps/app/src/pages/platform/form-failures/[id].vue
Normal file
21
apps/app/src/pages/platform/form-failures/[id].vue
Normal 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>
|
||||
Reference in New Issue
Block a user