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