Handle reference delete conflict in UI

This commit is contained in:
Kari Lintulaakso
2025-11-17 15:44:56 +02:00
parent 3c0ec6aae5
commit 72d11d9881
2 changed files with 150 additions and 5 deletions
@@ -16,6 +16,29 @@ import { useNotify } from '@/hooks/notification'
import { useEffect } from 'react'
import { createReferenceTitle } from './referenceFormatting'
import { useReturnNavigation } from '@/hooks/useReturnNavigation'
import type { FetchBaseQueryError } from '@reduxjs/toolkit/query'
import type { SerializedError } from '@reduxjs/toolkit'
const REFERENCE_DELETE_CONFLICT_MESSAGE = 'The Reference with associated updates cannot be deleted.'
const GENERIC_DELETE_MESSAGE = 'Could not delete item. Error happened.'
const resolveDeleteErrorMessage = (
error: FetchBaseQueryError | SerializedError | undefined
): string => {
if (error && 'status' in error) {
const errorData = (error.data ?? {}) as { message?: string }
if (typeof errorData.message === 'string' && errorData.message.trim().length > 0) {
return errorData.message
}
if (error.status === 409) {
return REFERENCE_DELETE_CONFLICT_MESSAGE
}
}
return GENERIC_DELETE_MESSAGE
}
export const ReferenceDetails = () => {
const { id } = useParams()
@@ -25,7 +48,7 @@ export const ReferenceDetails = () => {
}
const { isFetching, isError, data } = useGetReferenceDetailsQuery(id!, { skip: isNew })
const [editReferenceRequest, { isLoading: mutationLoading }] = useEditReferenceMutation()
const [deleteMutation, { isSuccess: deleteSuccess, isError: deleteError }] = useDeleteReferenceMutation()
const [deleteMutation, { isSuccess: deleteSuccess, error: deleteError }] = useDeleteReferenceMutation()
const { notify } = useNotify()
const navigate = useNavigate()
const { fallbackTarget } = useReturnNavigation({ fallback: '/reference' })
@@ -34,10 +57,8 @@ export const ReferenceDetails = () => {
if (deleteSuccess) {
notify('Deleted item successfully.')
navigate(fallbackTarget)
} else if (deleteError) {
notify('Could not delete item. Error happened.', 'error')
}
}, [deleteSuccess, deleteError, notify, navigate, fallbackTarget])
}, [deleteSuccess, notify, navigate, fallbackTarget])
if (isError) return <div>Error loading data</div>
if (isFetching || (!data && !isNew) || mutationLoading) return <CircularProgress />
@@ -57,7 +78,12 @@ export const ReferenceDetails = () => {
}
const deleteFunction = async () => {
await deleteMutation(parseInt(id!)).unwrap()
try {
await deleteMutation(parseInt(id!)).unwrap()
} catch (error) {
const message = resolveDeleteErrorMessage(error ?? deleteError)
notify(message, 'error')
}
}
const tabs: TabType[] = [
@@ -0,0 +1,119 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { Notification, NotificationContextProvider } from '@/components/Notification'
import { ReferenceDetails } from '@/components/Reference/ReferenceDetails'
import { useDeleteReferenceMutation, useEditReferenceMutation, useGetReferenceDetailsQuery } from '@/redux/referenceReducer'
import type { ReferenceDetailsType } from '@/shared/types'
jest.mock('@/components/DetailView/DetailView', () => ({
DetailView: ({ deleteFunction }: { deleteFunction?: () => Promise<void> }) => (
<button onClick={() => deleteFunction && deleteFunction()}>Delete</button>
),
}))
jest.mock('lodash-es', () => ({
cloneDeep: (value: unknown) => value,
}))
jest.mock('@/util/config', () => ({
BACKEND_URL: '',
ENABLE_WRITE: true,
ENV: 'test',
}))
jest.mock('@/redux/referenceReducer', () => ({
useGetReferenceDetailsQuery: jest.fn(),
useEditReferenceMutation: jest.fn(),
useDeleteReferenceMutation: jest.fn(),
}))
jest.mock('@/components/Reference/Tabs/ReferenceTab', () => ({ ReferenceTab: () => <div>Reference Tab</div> }))
jest.mock('@/components/Reference/Tabs/LocalityTab', () => ({ LocalityTab: () => <div>Locality Tab</div> }))
jest.mock('@/components/Reference/Tabs/SpeciesTab', () => ({ SpeciesTab: () => <div>Species Tab</div> }))
jest.mock('@/components/Reference/referenceFormatting', () => ({ createReferenceTitle: () => 'Reference title' }))
jest.mock('@/hooks/useReturnNavigation', () => ({ useReturnNavigation: () => ({ fallbackTarget: '/reference' }) }))
jest.mock('@/components/TimeBound/TimeBoundTable', () => ({ TimeBoundTable: () => <div>TimeBound Table</div> }))
jest.mock('@/components/Page', () => ({
usePageContext: () => ({
editRights: { delete: true, edit: true },
previousTableUrls: [],
setPreviousTableUrls: jest.fn(),
viewName: 'reference',
idList: [],
setIdList: jest.fn(),
idFieldName: 'rid',
createTitle: jest.fn(),
createSubtitle: jest.fn(),
sqlLimit: 25,
sqlOffset: 0,
sqlColumnFilters: [],
sqlOrderBy: [],
setSqlLimit: jest.fn(),
setSqlOffset: jest.fn(),
setSqlColumnFilters: jest.fn(),
setSqlOrderBy: jest.fn(),
}),
}))
const conflictError = {
status: 409,
data: { message: 'The Reference with associated updates cannot be deleted.' },
}
describe('ReferenceDetails deletion', () => {
const originalConfirm = window.confirm
const mockDeleteTrigger = jest.fn()
beforeEach(() => {
jest.clearAllMocks()
;(useGetReferenceDetailsQuery as jest.Mock).mockReturnValue({
isFetching: false,
isError: false,
data: {
rid: 1,
ref_authors: [],
ref_journal: { journal_id: 1, journal_title: '', short_title: '', alt_title: '', ISSN: '' },
ref_ref_type: { ref_type: '' },
exact_date: null,
} as unknown as ReferenceDetailsType,
})
;(useEditReferenceMutation as jest.Mock).mockReturnValue([
jest.fn(() => ({ unwrap: jest.fn() })),
{ isLoading: false },
])
mockDeleteTrigger.mockReturnValue({ unwrap: () => Promise.reject(conflictError) })
;(useDeleteReferenceMutation as jest.Mock).mockReturnValue([
mockDeleteTrigger,
{ isSuccess: false, error: conflictError },
])
window.confirm = jest.fn(() => true)
})
afterEach(() => {
window.confirm = originalConfirm
})
it('shows a conflict message when deletion fails due to linked updates', async () => {
const user = userEvent.setup()
render(
<NotificationContextProvider>
<Notification />
<MemoryRouter initialEntries={[{ pathname: '/reference/1' }]}>
<Routes>
<Route path="/reference/:id" element={<ReferenceDetails />} />
</Routes>
</MemoryRouter>
</NotificationContextProvider>
)
await user.click(await screen.findByRole('button', { name: /delete/i }))
await waitFor(() => {
expect(screen.getByText('The Reference with associated updates cannot be deleted.')).toBeTruthy()
})
})
})