mirror of
https://github.com/nowcommunity/nowdatabase.git
synced 2026-10-12 19:13:52 +03:00
Merge pull request #1172 from nowcommunity/feature/892-admin-delete-person
Add protected person deletion
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { PersonDetailsType } from '../../../../frontend/src/shared/types'
|
||||
|
||||
export const existingPerson: Omit<PersonDetailsType, 'user'> = {
|
||||
export const existingPerson: Omit<PersonDetailsType, 'user' | 'project_relations' | 'coordinator_relations'> = {
|
||||
initials: 'AD',
|
||||
first_name: 'adf',
|
||||
surname: 'ads',
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals'
|
||||
import { pool, nowDb } from '../../utils/db'
|
||||
import { login, logout, noPermError, resetDatabase, resetDatabaseTimeout, send } from '../utils'
|
||||
|
||||
const createPerson = async (initials: string, userId?: number) => {
|
||||
await nowDb.com_people.create({
|
||||
data: {
|
||||
initials,
|
||||
first_name: 'Delete',
|
||||
surname: 'Candidate',
|
||||
full_name: 'Delete Candidate',
|
||||
email: `${initials.toLowerCase()}@example.com`,
|
||||
organization: 'Test organization',
|
||||
country: 'Finland',
|
||||
user_id: userId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('Deleting person works', () => {
|
||||
beforeAll(async () => {
|
||||
await resetDatabase()
|
||||
}, resetDatabaseTimeout)
|
||||
|
||||
beforeEach(async () => {
|
||||
await login()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.end()
|
||||
})
|
||||
|
||||
it('deletes a person without user relation or protected activity', async () => {
|
||||
await createPerson('DEL-NO')
|
||||
|
||||
const deleteResult = await send('person/DEL-NO', 'DELETE')
|
||||
expect(deleteResult.status).toEqual(200)
|
||||
|
||||
const deletedPerson = await nowDb.com_people.findUnique({ where: { initials: 'DEL-NO' } })
|
||||
expect(deletedPerson).toBeNull()
|
||||
})
|
||||
|
||||
it('deletes a person and their user when the user has no protected activity', async () => {
|
||||
const user = await nowDb.com_users.create({
|
||||
data: {
|
||||
user_name: 'delete-person-user',
|
||||
now_user_group: 'eu',
|
||||
},
|
||||
})
|
||||
await createPerson('DEL-USR', user.user_id)
|
||||
|
||||
const deleteResult = await send('person/DEL-USR', 'DELETE')
|
||||
expect(deleteResult.status).toEqual(200)
|
||||
|
||||
const deletedPerson = await nowDb.com_people.findUnique({ where: { initials: 'DEL-USR' } })
|
||||
const deletedUser = await nowDb.com_users.findUnique({ where: { user_id: user.user_id } })
|
||||
expect(deletedPerson).toBeNull()
|
||||
expect(deletedUser).toBeNull()
|
||||
})
|
||||
|
||||
it('does not delete a person with update history or project and coordinator links', async () => {
|
||||
const deleteResult = await send<{ blockers: string[]; message: string }>('person/AD', 'DELETE')
|
||||
|
||||
expect(deleteResult.status).toEqual(409)
|
||||
expect(deleteResult.body.message).toEqual(
|
||||
'Person cannot be deleted because they are linked to: person has data update history; person is assigned to projects or coordinator groups.'
|
||||
)
|
||||
expect(deleteResult.body.blockers).toEqual([
|
||||
'person has data update history',
|
||||
'person is assigned to projects or coordinator groups',
|
||||
])
|
||||
|
||||
const person = await nowDb.com_people.findUnique({ where: { initials: 'AD' } })
|
||||
expect(person).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does not allow users to delete their own person record', async () => {
|
||||
const deleteResult = await send<{ blockers: string[]; message: string }>('person/TEST-SU', 'DELETE')
|
||||
|
||||
expect(deleteResult.status).toEqual(409)
|
||||
expect(deleteResult.body.message).toEqual('You cannot delete your own person record.')
|
||||
expect(deleteResult.body.blockers).toEqual(['self'])
|
||||
})
|
||||
|
||||
it('deleting fails without admin permissions', async () => {
|
||||
await createPerson('DEL-PERM')
|
||||
|
||||
logout()
|
||||
const deleteResultNoPerm = await send('person/DEL-PERM', 'DELETE')
|
||||
expect(deleteResultNoPerm.status).toEqual(403)
|
||||
|
||||
await login('testEr')
|
||||
const deleteResultEr = await send('person/DEL-PERM', 'DELETE')
|
||||
expect(deleteResultEr.status).toEqual(403)
|
||||
expect(deleteResultEr.body).toEqual(noPermError)
|
||||
|
||||
const person = await nowDb.com_people.findUnique({ where: { initials: 'DEL-PERM' } })
|
||||
expect(person).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -31,6 +31,37 @@ describe('Updating person works', () => {
|
||||
updatedPerson = body
|
||||
})
|
||||
|
||||
it('returns project and coordinator relations for a person', async () => {
|
||||
const { body, status } = await send<PersonDetailsType>('person/ER', 'GET')
|
||||
|
||||
expect(status).toEqual(200)
|
||||
expect(body.project_relations).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
pid: 3,
|
||||
relation: 'Member',
|
||||
proj_code: 'NOW',
|
||||
proj_name: 'NOW Database',
|
||||
}),
|
||||
])
|
||||
)
|
||||
expect(body.coordinator_relations).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 2,
|
||||
type: 'Region',
|
||||
name: 'region 44524b6e',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 7,
|
||||
type: 'Taxa',
|
||||
name: 'group bb181839',
|
||||
details: 'Carnivora / Felidae',
|
||||
}),
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('Contains correct data and full name is automatically updated', () => {
|
||||
const { initials, first_name, surname, full_name, email, organization, country } = updatedPerson!
|
||||
expect(initials).toEqual(existingPerson.initials)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Router, Request } from 'express'
|
||||
import { getAllPersons, getPersonDetails, validateEntirePerson } from '../services/person'
|
||||
import { deletePerson, getAllPersons, getPersonDetails, validateEntirePerson } from '../services/person'
|
||||
import {
|
||||
Role,
|
||||
PersonDetailsType,
|
||||
@@ -73,4 +73,14 @@ router.put(
|
||||
}
|
||||
)
|
||||
|
||||
router.delete('/:id', requireOneOf([Role.Admin]), async (req, res) => {
|
||||
const result = await deletePerson(req.params.id, req.user!.initials)
|
||||
|
||||
if (!result.deleted) {
|
||||
return res.status(result.status).send({ message: result.message, blockers: result.blockers })
|
||||
}
|
||||
|
||||
return res.status(200).send()
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { nowDb } from '../utils/db'
|
||||
import { EditDataType, PersonDetailsType } from '../../../frontend/src/shared/types'
|
||||
import {
|
||||
EditDataType,
|
||||
PersonCoordinatorRelation,
|
||||
PersonDetailsType,
|
||||
PersonProjectRelation,
|
||||
} from '../../../frontend/src/shared/types'
|
||||
import Prisma from '../../prisma/generated/now_test_client'
|
||||
import { ValidationObject } from '../../../frontend/src/shared/validators/validator'
|
||||
import { validatePerson } from '../../../frontend/src/shared/validators/person'
|
||||
|
||||
const PERSON_DELETE_BLOCKED_PREFIX = 'Person cannot be deleted because they are linked to'
|
||||
export const PERSON_DELETE_SELF_MESSAGE = 'You cannot delete your own person record.'
|
||||
|
||||
export const getAllPersons = async () => {
|
||||
const persons = await nowDb.com_people.findMany({})
|
||||
const users = await nowDb.com_users.findMany({})
|
||||
@@ -11,23 +19,115 @@ export const getAllPersons = async () => {
|
||||
return persons.map(person => ({ ...person, user: person.user_id ? userMap.get(person.user_id) : null }))
|
||||
}
|
||||
|
||||
const formatSpeciesCoordinatorTaxa = (
|
||||
taxa: Array<{
|
||||
order_name: string
|
||||
family_name: string
|
||||
}>
|
||||
): string | undefined => {
|
||||
if (taxa.length === 0) return undefined
|
||||
return taxa.map(({ order_name, family_name }) => [order_name, family_name].filter(Boolean).join(' / ')).join(', ')
|
||||
}
|
||||
|
||||
export const getPersonRelations = async (
|
||||
initials: string
|
||||
): Promise<{
|
||||
project_relations: PersonProjectRelation[]
|
||||
coordinator_relations: PersonCoordinatorRelation[]
|
||||
}> => {
|
||||
const [contactProjects, memberProjects, regionalCoordinators, speciesCoordinators, stratigraphyCoordinators] =
|
||||
await Promise.all([
|
||||
nowDb.now_proj.findMany({
|
||||
where: { contact: initials },
|
||||
select: { pid: true, proj_code: true, proj_name: true, proj_status: true },
|
||||
orderBy: [{ proj_name: 'asc' }, { pid: 'asc' }],
|
||||
}),
|
||||
nowDb.now_proj_people.findMany({
|
||||
where: { initials },
|
||||
select: {
|
||||
now_proj: { select: { pid: true, proj_code: true, proj_name: true, proj_status: true } },
|
||||
},
|
||||
orderBy: [{ now_proj: { proj_name: 'asc' } }, { pid: 'asc' }],
|
||||
}),
|
||||
nowDb.now_reg_coord_people.findMany({
|
||||
where: { initials },
|
||||
select: {
|
||||
now_reg_coord: { select: { reg_coord_id: true, region: true } },
|
||||
},
|
||||
orderBy: [{ now_reg_coord: { region: 'asc' } }, { reg_coord_id: 'asc' }],
|
||||
}),
|
||||
nowDb.now_sp_coord_people.findMany({
|
||||
where: { initials },
|
||||
select: {
|
||||
now_sp_coord: {
|
||||
select: {
|
||||
sp_coord_id: true,
|
||||
tax_group: true,
|
||||
now_sp_coord_taxa: {
|
||||
select: { order_name: true, family_name: true },
|
||||
orderBy: [{ order_name: 'asc' }, { family_name: 'asc' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ now_sp_coord: { tax_group: 'asc' } }, { sp_coord_id: 'asc' }],
|
||||
}),
|
||||
nowDb.now_strat_coord_people.findMany({
|
||||
where: { initials },
|
||||
select: {
|
||||
now_strat_coord: { select: { strat_coord_id: true, title: true } },
|
||||
},
|
||||
orderBy: [{ now_strat_coord: { title: 'asc' } }, { strat_coord_id: 'asc' }],
|
||||
}),
|
||||
])
|
||||
|
||||
const project_relations: PersonProjectRelation[] = [
|
||||
...contactProjects.map(project => ({ ...project, relation: 'Contact' as const })),
|
||||
...memberProjects.map(({ now_proj }) => ({ ...now_proj, relation: 'Member' as const })),
|
||||
]
|
||||
|
||||
const coordinator_relations: PersonCoordinatorRelation[] = [
|
||||
...regionalCoordinators.map(({ now_reg_coord }) => ({
|
||||
id: now_reg_coord.reg_coord_id,
|
||||
type: 'Region' as const,
|
||||
name: now_reg_coord.region,
|
||||
})),
|
||||
...speciesCoordinators.map(({ now_sp_coord }) => ({
|
||||
id: now_sp_coord.sp_coord_id,
|
||||
type: 'Taxa' as const,
|
||||
name: now_sp_coord.tax_group,
|
||||
details: formatSpeciesCoordinatorTaxa(now_sp_coord.now_sp_coord_taxa),
|
||||
})),
|
||||
...stratigraphyCoordinators.map(({ now_strat_coord }) => ({
|
||||
id: now_strat_coord.strat_coord_id,
|
||||
type: 'Stratigraphy' as const,
|
||||
name: now_strat_coord.title,
|
||||
})),
|
||||
]
|
||||
|
||||
return { project_relations, coordinator_relations }
|
||||
}
|
||||
|
||||
export const getPersonDetails = async (id: string) => {
|
||||
// TODO: Check if user has access
|
||||
|
||||
const person = await nowDb.com_people.findUnique({
|
||||
where: { initials: id },
|
||||
})
|
||||
const [person, relations] = await Promise.all([
|
||||
nowDb.com_people.findUnique({
|
||||
where: { initials: id },
|
||||
}),
|
||||
getPersonRelations(id),
|
||||
])
|
||||
|
||||
if (!person) return null
|
||||
|
||||
if (!person.user_id) return { ...person, user: null }
|
||||
if (!person.user_id) return { ...person, user: null, now_user_group: '', ...relations }
|
||||
|
||||
const user = await nowDb.com_users.findUnique({
|
||||
where: { user_id: person.user_id },
|
||||
select: { user_id: true, user_name: true, last_login: true, now_user_group: true },
|
||||
})
|
||||
|
||||
return { ...person, user, now_user_group: user?.now_user_group }
|
||||
return { ...person, user, now_user_group: user?.now_user_group, ...relations }
|
||||
}
|
||||
|
||||
export const validateEntirePerson = (editedFields: EditDataType<Prisma.com_people>) => {
|
||||
@@ -39,3 +139,82 @@ export const validateEntirePerson = (editedFields: EditDataType<Prisma.com_peopl
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
const hasAny = async (checks: Array<Promise<number>>): Promise<boolean> => {
|
||||
const counts = await Promise.all(checks)
|
||||
return counts.some(count => count > 0)
|
||||
}
|
||||
|
||||
const hasUpdateActivity = async (initials: string): Promise<boolean> => {
|
||||
return hasAny([
|
||||
nowDb.now_lau.count({ where: { OR: [{ lau_coordinator: initials }, { lau_authorizer: initials }] } }),
|
||||
nowDb.now_sau.count({ where: { OR: [{ sau_coordinator: initials }, { sau_authorizer: initials }] } }),
|
||||
nowDb.now_tau.count({ where: { OR: [{ tau_coordinator: initials }, { tau_authorizer: initials }] } }),
|
||||
nowDb.now_bau.count({ where: { OR: [{ bau_coordinator: initials }, { bau_authorizer: initials }] } }),
|
||||
nowDb.now_time_update.count({ where: { OR: [{ coordinator: initials }, { authorizer: initials }] } }),
|
||||
])
|
||||
}
|
||||
|
||||
const hasCoordinationOrProjectLinks = async (initials: string): Promise<boolean> => {
|
||||
return hasAny([
|
||||
nowDb.now_proj.count({ where: { contact: initials } }),
|
||||
nowDb.now_proj_people.count({ where: { initials } }),
|
||||
nowDb.now_reg_coord_people.count({ where: { initials } }),
|
||||
nowDb.now_sp_coord_people.count({ where: { initials } }),
|
||||
nowDb.now_strat_coord_people.count({ where: { initials } }),
|
||||
])
|
||||
}
|
||||
|
||||
export const getPersonDeleteBlockers = async (initials: string): Promise<string[]> => {
|
||||
const blockers: string[] = []
|
||||
|
||||
if (await hasUpdateActivity(initials)) {
|
||||
blockers.push('person has data update history')
|
||||
}
|
||||
|
||||
if (await hasCoordinationOrProjectLinks(initials)) {
|
||||
blockers.push('person is assigned to projects or coordinator groups')
|
||||
}
|
||||
|
||||
return blockers
|
||||
}
|
||||
|
||||
export const formatPersonDeleteBlockedMessage = (blockers: string[]): string => {
|
||||
if (blockers.length === 0) return 'Person cannot be deleted.'
|
||||
return `${PERSON_DELETE_BLOCKED_PREFIX}: ${blockers.join('; ')}.`
|
||||
}
|
||||
|
||||
export const deletePerson = async (initials: string, currentUserInitials: string) => {
|
||||
if (initials === currentUserInitials) {
|
||||
return { deleted: false, status: 409 as const, message: PERSON_DELETE_SELF_MESSAGE, blockers: ['self'] }
|
||||
}
|
||||
|
||||
const person = await nowDb.com_people.findUnique({
|
||||
where: { initials },
|
||||
select: { initials: true, user_id: true },
|
||||
})
|
||||
|
||||
if (!person) {
|
||||
return { deleted: false, status: 404 as const, message: 'Person not found.', blockers: [] }
|
||||
}
|
||||
|
||||
const blockers = await getPersonDeleteBlockers(initials)
|
||||
if (blockers.length > 0) {
|
||||
return {
|
||||
deleted: false,
|
||||
status: 409 as const,
|
||||
message: formatPersonDeleteBlockedMessage(blockers),
|
||||
blockers,
|
||||
}
|
||||
}
|
||||
|
||||
await nowDb.$transaction(async prisma => {
|
||||
await prisma.com_people.delete({ where: { initials } })
|
||||
|
||||
if (person.user_id) {
|
||||
await prisma.com_users.deleteMany({ where: { user_id: person.user_id } })
|
||||
}
|
||||
})
|
||||
|
||||
return { deleted: true, status: 200 as const, message: null, blockers: [] }
|
||||
}
|
||||
|
||||
@@ -271,6 +271,8 @@ export const emptyPerson = {
|
||||
email: '',
|
||||
organization: '',
|
||||
country: '',
|
||||
project_relations: [],
|
||||
coordinator_relations: [],
|
||||
} as unknown as PersonDetailsType
|
||||
|
||||
export const defaultPagination: MRT_PaginationState = { pageIndex: 0, pageSize: 15 }
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
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 { PersonDetails } from '@/components/Person/PersonDetails'
|
||||
import { PersonDetailsType, Role } from '@/shared/types'
|
||||
|
||||
jest.mock('@/components/DetailView/DetailView', () => ({
|
||||
DetailView: (props: { deleteFunction?: () => Promise<void> }) => {
|
||||
const handleDelete = () => {
|
||||
if (props.deleteFunction) void props.deleteFunction()
|
||||
}
|
||||
return <button onClick={handleDelete}>Delete</button>
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/components/Person/Tabs/PersonTab', () => ({
|
||||
PersonTab: () => <div data-testid="person-tab" />,
|
||||
}))
|
||||
|
||||
jest.mock('@/components/Person/Tabs/PersonRelationsTab', () => ({
|
||||
PersonRelationsTab: () => <div data-testid="person-relations-tab" />,
|
||||
}))
|
||||
|
||||
const mockUseGetPersonDetailsQuery = jest.fn()
|
||||
const mockUseGetPersonDetailsIdMutation = jest.fn()
|
||||
const mockUseEditPersonMutation = jest.fn()
|
||||
const mockUseDeletePersonMutation = jest.fn()
|
||||
const mockUseUser = jest.fn()
|
||||
const mockNotify = jest.fn()
|
||||
const mockNavigate = jest.fn()
|
||||
let deletePersonMock: jest.Mock
|
||||
|
||||
jest.mock('@/redux/personReducer', () => ({
|
||||
useGetPersonDetailsQuery: (id: string, options?: unknown) => mockUseGetPersonDetailsQuery(id, options),
|
||||
useGetPersonDetailsIdMutation: () => mockUseGetPersonDetailsIdMutation(),
|
||||
useEditPersonMutation: () => mockUseEditPersonMutation(),
|
||||
useDeletePersonMutation: () => mockUseDeletePersonMutation(),
|
||||
}))
|
||||
|
||||
jest.mock('@/hooks/user', () => ({
|
||||
useUser: () => mockUseUser(),
|
||||
}))
|
||||
|
||||
jest.mock('@/hooks/notification', () => ({
|
||||
useNotify: () => ({ notify: mockNotify }),
|
||||
}))
|
||||
|
||||
jest.mock('react-router-dom', () => {
|
||||
const actualRouterDom = jest.requireActual<typeof import('react-router-dom')>('react-router-dom')
|
||||
return {
|
||||
...actualRouterDom,
|
||||
useNavigate: () => mockNavigate,
|
||||
}
|
||||
})
|
||||
|
||||
const person: PersonDetailsType = {
|
||||
initials: 'DEL-UI',
|
||||
first_name: 'Delete',
|
||||
surname: 'Ui',
|
||||
full_name: 'Delete Ui',
|
||||
format: null,
|
||||
email: 'delete-ui@example.com',
|
||||
user_id: null,
|
||||
organization: 'Test organization',
|
||||
country: 'Finland',
|
||||
password_set: null,
|
||||
used_morph: null,
|
||||
used_now: null,
|
||||
used_gene: null,
|
||||
user: null,
|
||||
now_user_group: '',
|
||||
project_relations: [],
|
||||
coordinator_relations: [],
|
||||
}
|
||||
|
||||
const renderPersonDetails = () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/person/DEL-UI']}>
|
||||
<Routes>
|
||||
<Route path="/person/:id" element={<PersonDetails />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
)
|
||||
}
|
||||
|
||||
describe('PersonDetails', () => {
|
||||
beforeEach(() => {
|
||||
mockUseUser.mockReturnValue({ initials: 'TEST-SU', role: Role.Admin })
|
||||
mockUseGetPersonDetailsQuery.mockReturnValue({ data: person, isLoading: false, isError: false })
|
||||
mockUseGetPersonDetailsIdMutation.mockReturnValue([jest.fn()])
|
||||
mockUseEditPersonMutation.mockReturnValue([jest.fn(), { isLoading: false }])
|
||||
deletePersonMock = jest.fn(() => ({ unwrap: () => Promise.resolve() }))
|
||||
mockUseDeletePersonMutation.mockReturnValue([deletePersonMock, { isLoading: false }])
|
||||
mockNotify.mockReset()
|
||||
mockNavigate.mockReset()
|
||||
})
|
||||
|
||||
it('deletes the person and navigates back to the list', async () => {
|
||||
renderPersonDetails()
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /delete/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deletePersonMock).toHaveBeenCalledWith(person.initials)
|
||||
})
|
||||
expect(mockNotify).toHaveBeenCalledWith('Deleted person successfully.')
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/person')
|
||||
})
|
||||
|
||||
it('shows the backend message when deleting is blocked', async () => {
|
||||
deletePersonMock.mockReturnValue({
|
||||
unwrap: () =>
|
||||
Promise.reject(
|
||||
Object.assign(new Error('Delete blocked'), {
|
||||
status: 409,
|
||||
data: {
|
||||
message:
|
||||
'Person cannot be deleted because they are linked to: person has data update history; person is assigned to projects or coordinator groups.',
|
||||
},
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
renderPersonDetails()
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /delete/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
'Person cannot be deleted because they are linked to: person has data update history; person is assigned to projects or coordinator groups.',
|
||||
'error'
|
||||
)
|
||||
})
|
||||
expect(mockNavigate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
useDeletePersonMutation,
|
||||
useEditPersonMutation,
|
||||
useGetPersonDetailsIdMutation,
|
||||
useGetPersonDetailsQuery,
|
||||
@@ -7,17 +8,37 @@ import {
|
||||
import { CircularProgress } from '@mui/material'
|
||||
import { DetailView, TabType } from '../DetailView/DetailView'
|
||||
import { PersonTab } from './Tabs/PersonTab'
|
||||
import { PersonRelationsTab } from './Tabs/PersonRelationsTab'
|
||||
import { useUser } from '@/hooks/user'
|
||||
import { EditDataType, PersonDetailsType, Role, ValidationErrors } from '@/shared/types'
|
||||
import { validatePerson } from '@/shared/validators/person'
|
||||
import { useNotify } from '@/hooks/notification'
|
||||
import { useEffect } from 'react'
|
||||
import { emptyPerson } from '../DetailView/common/defaultValues'
|
||||
import type { FetchBaseQueryError } from '@reduxjs/toolkit/query'
|
||||
|
||||
const getDeleteErrorMessage = (error: unknown): string => {
|
||||
const fetchError = error as FetchBaseQueryError
|
||||
if (
|
||||
fetchError &&
|
||||
typeof fetchError === 'object' &&
|
||||
'data' in fetchError &&
|
||||
fetchError.data &&
|
||||
typeof fetchError.data === 'object' &&
|
||||
'message' in fetchError.data &&
|
||||
typeof fetchError.data.message === 'string'
|
||||
) {
|
||||
return fetchError.data.message
|
||||
}
|
||||
|
||||
return 'Could not delete person.'
|
||||
}
|
||||
|
||||
export const PersonDetails = () => {
|
||||
const { id: idFromUrl } = useParams()
|
||||
const user = useUser()
|
||||
const [editPersonRequest, { isLoading: mutationLoading }] = useEditPersonMutation()
|
||||
const [deletePersonRequest, { isLoading: deleteLoading }] = useDeletePersonMutation()
|
||||
const { notify } = useNotify()
|
||||
const navigate = useNavigate()
|
||||
|
||||
@@ -70,15 +91,31 @@ export const PersonDetails = () => {
|
||||
}
|
||||
|
||||
if (isError) return <div>Error loading data</div>
|
||||
if (isLoading || (!data && !isNew) || mutationLoading) return <CircularProgress />
|
||||
if (isLoading || (!data && !isNew) || mutationLoading || deleteLoading) return <CircularProgress />
|
||||
|
||||
document.title = isNew ? 'New person' : `User - ${data!.user?.user_name}`
|
||||
|
||||
const deleteFunction = async () => {
|
||||
if (!id) return
|
||||
|
||||
try {
|
||||
await deletePersonRequest(id).unwrap()
|
||||
notify('Deleted person successfully.')
|
||||
navigate('/person')
|
||||
} catch (error) {
|
||||
notify(getDeleteErrorMessage(error), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const tabs: TabType[] = [
|
||||
{
|
||||
title: 'Person',
|
||||
content: <PersonTab />,
|
||||
},
|
||||
{
|
||||
title: 'Relations',
|
||||
content: <PersonRelationsTab />,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -90,6 +127,7 @@ export const PersonDetails = () => {
|
||||
tabs={tabs}
|
||||
data={isNew ? emptyPerson : data!}
|
||||
validator={validatePerson}
|
||||
deleteFunction={deleteFunction}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it, jest } from '@jest/globals'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { PersonRelationsTab } from './PersonRelationsTab'
|
||||
import { PersonDetailsType } from '@/shared/types'
|
||||
|
||||
const mockUseDetailContext = jest.fn()
|
||||
|
||||
jest.mock('@/components/DetailView/Context/DetailContext', () => ({
|
||||
useDetailContext: () => mockUseDetailContext(),
|
||||
}))
|
||||
|
||||
const person = {
|
||||
project_relations: [
|
||||
{
|
||||
pid: 3,
|
||||
proj_code: 'NOW',
|
||||
proj_name: 'NOW Database',
|
||||
proj_status: 'current',
|
||||
relation: 'Contact',
|
||||
},
|
||||
{
|
||||
pid: 14,
|
||||
proj_code: 'WINE',
|
||||
proj_name: 'Workgroup on Insectivores',
|
||||
proj_status: 'current',
|
||||
relation: 'Member',
|
||||
},
|
||||
],
|
||||
coordinator_relations: [
|
||||
{
|
||||
id: 2,
|
||||
type: 'Region',
|
||||
name: 'Europe',
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
type: 'Taxa',
|
||||
name: 'Carnivora',
|
||||
details: 'Carnivora / Felidae',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
type: 'Stratigraphy',
|
||||
name: 'European Neogene',
|
||||
},
|
||||
],
|
||||
} as PersonDetailsType
|
||||
|
||||
describe('PersonRelationsTab', () => {
|
||||
it('lists project and coordinator relations', () => {
|
||||
mockUseDetailContext.mockReturnValue({ data: person })
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PersonRelationsTab />
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Project Relations')).toBeTruthy()
|
||||
expect(screen.getByText('NOW Database')).toBeTruthy()
|
||||
expect(screen.getByText('Contact')).toBeTruthy()
|
||||
expect(screen.getByText('Workgroup on Insectivores')).toBeTruthy()
|
||||
expect(screen.getByText('Member')).toBeTruthy()
|
||||
|
||||
expect(screen.getByText('Coordinator Relations')).toBeTruthy()
|
||||
expect(screen.getByText('Region')).toBeTruthy()
|
||||
expect(screen.getByText('Europe')).toBeTruthy()
|
||||
expect(screen.getByText('Taxa')).toBeTruthy()
|
||||
expect(screen.getByText('Carnivora / Felidae')).toBeTruthy()
|
||||
expect(screen.getByText('Stratigraphy')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows empty messages when no relations exist', () => {
|
||||
mockUseDetailContext.mockReturnValue({
|
||||
data: { project_relations: [], coordinator_relations: [] },
|
||||
})
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PersonRelationsTab />
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
expect(screen.getAllByText('No relations.')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Link as RouterLink } from 'react-router-dom'
|
||||
import { Box, Link, Table, TableBody, TableCell, TableHead, TableRow, Typography } from '@mui/material'
|
||||
import { Grouped } from '@/components/DetailView/common/tabLayoutHelpers'
|
||||
import { useDetailContext } from '@/components/DetailView/Context/DetailContext'
|
||||
import { PersonCoordinatorRelation, PersonDetailsType, PersonProjectRelation } from '@/shared/types'
|
||||
|
||||
const EmptyMessage = () => (
|
||||
<Typography color="text.secondary" sx={{ fontStyle: 'italic' }}>
|
||||
No relations.
|
||||
</Typography>
|
||||
)
|
||||
|
||||
const ProjectRelationsTable = ({ relations }: { relations: PersonProjectRelation[] }) => {
|
||||
if (relations.length === 0) return <EmptyMessage />
|
||||
|
||||
return (
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Relation</TableCell>
|
||||
<TableCell>Project</TableCell>
|
||||
<TableCell>Code</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{relations.map((relation, index) => (
|
||||
<TableRow key={`${relation.relation}-${relation.pid}-${index}`}>
|
||||
<TableCell>{relation.relation}</TableCell>
|
||||
<TableCell>
|
||||
<Link component={RouterLink} to={`/project/${relation.pid}`}>
|
||||
{relation.proj_name || relation.pid}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>{relation.proj_code ?? ''}</TableCell>
|
||||
<TableCell>{relation.proj_status ?? ''}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
const CoordinatorRelationsTable = ({ relations }: { relations: PersonCoordinatorRelation[] }) => {
|
||||
if (relations.length === 0) return <EmptyMessage />
|
||||
|
||||
return (
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Type</TableCell>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Details</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{relations.map(relation => (
|
||||
<TableRow key={`${relation.type}-${relation.id}`}>
|
||||
<TableCell>{relation.type}</TableCell>
|
||||
<TableCell>{relation.name}</TableCell>
|
||||
<TableCell>{relation.details ?? ''}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
export const PersonRelationsTab = () => {
|
||||
const { data } = useDetailContext<PersonDetailsType>()
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', rowGap: '1em' }}>
|
||||
<Grouped title="Project Relations">
|
||||
<ProjectRelationsTable relations={data.project_relations ?? []} />
|
||||
</Grouped>
|
||||
<Grouped title="Coordinator Relations">
|
||||
<CoordinatorRelationsTable relations={data.coordinator_relations ?? []} />
|
||||
</Grouped>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -28,8 +28,20 @@ const personsApi = api.injectEndpoints({
|
||||
}),
|
||||
invalidatesTags: (result, _error, { initials }) => (result ? [{ type: 'person', id: initials }, 'persons'] : []),
|
||||
}),
|
||||
deletePerson: builder.mutation<void, string>({
|
||||
query: initials => ({
|
||||
url: `/person/${initials}`,
|
||||
method: 'DELETE',
|
||||
}),
|
||||
invalidatesTags: (_result, _error, initials) => [{ type: 'person', id: initials }, 'persons'],
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
export const { useGetAllPersonsQuery, useGetPersonDetailsQuery, useEditPersonMutation, useGetPersonDetailsIdMutation } =
|
||||
personsApi
|
||||
export const {
|
||||
useGetAllPersonsQuery,
|
||||
useGetPersonDetailsQuery,
|
||||
useEditPersonMutation,
|
||||
useGetPersonDetailsIdMutation,
|
||||
useDeletePersonMutation,
|
||||
} = personsApi
|
||||
|
||||
@@ -113,8 +113,25 @@ export type LocalityUpdate = Prisma.now_lau & { now_lr: LocalityReference[] } &
|
||||
export type SpeciesUpdate = Prisma.now_sau & { now_sr: SpeciesReference[] } & { updates: UpdateLog[] }
|
||||
export type Museum = Omit<Prisma.com_mlist, 'used_morph' | 'used_now' | 'used_gene'>
|
||||
export type MuseumLocalities = Prisma.com_mlist & { localities: Prisma.now_loc[] }
|
||||
export type PersonProjectRelation = {
|
||||
pid: number
|
||||
proj_code: string | null
|
||||
proj_name: string | null
|
||||
proj_status: string | null
|
||||
relation: 'Contact' | 'Member'
|
||||
}
|
||||
|
||||
export type PersonCoordinatorRelation = {
|
||||
id: number
|
||||
type: 'Region' | 'Taxa' | 'Stratigraphy'
|
||||
name: string
|
||||
details?: string
|
||||
}
|
||||
|
||||
export type PersonDetailsType = Prisma.com_people & { user: Omit<Prisma.com_users, 'password, newpassword'> | null } & {
|
||||
now_user_group: string
|
||||
project_relations?: PersonProjectRelation[]
|
||||
coordinator_relations?: PersonCoordinatorRelation[]
|
||||
}
|
||||
// allow components to attach the included person details and a UI-only row state
|
||||
export type ProjectPeople = Prisma.now_proj_people & {
|
||||
|
||||
Reference in New Issue
Block a user