mirror of
https://github.com/nowcommunity/nowdatabase.git
synced 2026-10-12 19:13:52 +03:00
update project update process in frontend and backend, and add project validators (#1230)
* update project update prosess in frontend, and add project validators * change project update process in backend * update project backend files, projectReducer, project validators and tests * update project api tests, and error handling in ProjectDetails * update frontend project creation form * remove ProjectEditPage tests and fix ProjectNewPage tests * update project api tests, project write process, and project validators * remove unneeded files relating to projects, and merge project/ and projects/ route files * remove project validators from backend, and update other project files * allow all users to view projects table * fix import issue * update project api tests to use correct url, and add --runInBand flag to backend unit tests * update cypress test
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
"dev": "npx tsx watch --clear-screen=false src/index.ts",
|
||||
"build": "tsc",
|
||||
"test:api": "jest src/api-tests --runInBand --coverage --config jest-config.js --detectOpenHandles --forceExit --silent",
|
||||
"test:unit": "jest src/unit-tests --coverage --config jest-config.js --detectOpenHandles --forceExit --silent",
|
||||
"test:unit": "jest src/unit-tests --runInBand --coverage --config jest-config.js --detectOpenHandles --forceExit --silent",
|
||||
"test:api:local": "DOTENV_CONFIG_PATH=../.test.env npm run test:api -- --setupFiles dotenv/config",
|
||||
"start": "node build/index.js",
|
||||
"lint": "eslint --report-unused-disable-directives --max-warnings 0",
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals'
|
||||
import { ProjectDetailsType } from '../../../../frontend/src/shared/types'
|
||||
import { pool } from '../../utils/db'
|
||||
import { login, logout, noPermError, resetDatabase, resetDatabaseTimeout, send } from '../utils'
|
||||
import {
|
||||
existingPerson,
|
||||
invalidMemberArrayError1,
|
||||
invalidMemberArrayError2,
|
||||
newProjectBasis,
|
||||
newProjectBasisWithoutCoordinator,
|
||||
newProjectBasisWithoutMemberArray,
|
||||
noCoordinatorError,
|
||||
noMemberArrayError,
|
||||
} from './data'
|
||||
|
||||
let createdProject: ProjectDetailsType | null = null
|
||||
|
||||
describe('Creating new project works', () => {
|
||||
beforeAll(async () => {
|
||||
await resetDatabase()
|
||||
}, resetDatabaseTimeout)
|
||||
beforeEach(async () => {
|
||||
await login()
|
||||
})
|
||||
afterAll(async () => {
|
||||
await pool.end()
|
||||
})
|
||||
|
||||
it('Request succeeds and returns valid number id', async () => {
|
||||
const { body: resultBody, status: getReqStatus } = await send<{ pid: number }>('project/', 'PUT', {
|
||||
project: newProjectBasis,
|
||||
})
|
||||
const { pid: createdId } = resultBody
|
||||
|
||||
expect(typeof createdId).toEqual('number')
|
||||
expect(getReqStatus).toEqual(200)
|
||||
|
||||
const { body, status: getReqStat } = await send<ProjectDetailsType>(`project/${createdId}`, 'GET')
|
||||
expect(getReqStat).toEqual(200)
|
||||
createdProject = body
|
||||
})
|
||||
|
||||
it('Contains correct data', () => {
|
||||
const { pid, proj_name, proj_code, proj_status, proj_records } = createdProject!
|
||||
expect(pid).toBeDefined()
|
||||
expect(proj_name).toEqual(newProjectBasis.proj_name)
|
||||
expect(proj_code).toEqual(newProjectBasis.proj_code)
|
||||
expect(proj_status).toEqual(newProjectBasis.proj_status)
|
||||
expect(proj_records).toEqual(newProjectBasis.proj_records)
|
||||
})
|
||||
|
||||
it('Creation succeeds with empty member list', async () => {
|
||||
const { status: getReqStatus } = await send<{ pid: number }>('project/', 'PUT', {
|
||||
project: { ...newProjectBasis, now_proj_people: [] },
|
||||
})
|
||||
|
||||
expect(getReqStatus).toEqual(200)
|
||||
})
|
||||
|
||||
it('Creation fails with no member list', async () => {
|
||||
const { body: resultBody, status: getReqStatus } = await send('project/', 'PUT', {
|
||||
project: { ...newProjectBasisWithoutMemberArray },
|
||||
})
|
||||
|
||||
//expect(typeof resultBody).toBe('array')
|
||||
expect(resultBody).toContainEqual(noMemberArrayError)
|
||||
expect(getReqStatus).toEqual(403)
|
||||
})
|
||||
|
||||
it('Creation succeeds with empty member list', async () => {
|
||||
const { status: getReqStatus } = await send<{ pid: number }>('project/', 'PUT', {
|
||||
project: { ...newProjectBasis, now_proj_people: [] },
|
||||
})
|
||||
|
||||
expect(getReqStatus).toEqual(200)
|
||||
})
|
||||
|
||||
it('Creation fails with no coordinator', async () => {
|
||||
const { body: resultBody, status: getReqStatus } = await send<{ pid: number }>('project/', 'PUT', {
|
||||
project: { ...newProjectBasisWithoutCoordinator },
|
||||
})
|
||||
|
||||
//expect(typeof resultBody).toBe('array')
|
||||
expect(resultBody).toContainEqual(noCoordinatorError)
|
||||
expect(getReqStatus).toEqual(403)
|
||||
})
|
||||
|
||||
it('Creation fails with coordinator that does not have a matching user', async () => {
|
||||
const { status: getReqStatus } = await send<{ pid: number }>('project/', 'PUT', {
|
||||
project: { ...newProjectBasis, contact: 'NOTEXIST' },
|
||||
})
|
||||
|
||||
expect(getReqStatus).toEqual(500)
|
||||
})
|
||||
|
||||
it('Creation fails for non-admin users', async () => {
|
||||
logout()
|
||||
const { body: resultBodyAnon, status: resultStatusAnon } = await send<{ pid: number }>('project/', 'PUT', {
|
||||
project: newProjectBasis,
|
||||
})
|
||||
expect(resultBodyAnon).toEqual(noPermError)
|
||||
expect(resultStatusAnon).toEqual(403)
|
||||
|
||||
await login('testEr')
|
||||
const { body: resultBodyEr, status: resultStatusEr } = await send<{ pid: number }>('project/', 'PUT', {
|
||||
project: newProjectBasis,
|
||||
})
|
||||
expect(resultBodyEr).toEqual(noPermError)
|
||||
expect(resultStatusEr).toEqual(403)
|
||||
|
||||
await login('testEu')
|
||||
const { body: resultBodyEu, status: resultStatusEu } = await send<{ pid: number }>('project/', 'PUT', {
|
||||
project: newProjectBasis,
|
||||
})
|
||||
expect(resultBodyEu).toEqual(noPermError)
|
||||
expect(resultStatusEu).toEqual(403)
|
||||
})
|
||||
|
||||
it('Creation fails with members without required fields', async () => {
|
||||
const { body: putReqBody, status: putReqStatus } = await send('project/', 'PUT', {
|
||||
project: { ...newProjectBasis, now_proj_people: [{ initials: existingPerson.initials }] },
|
||||
})
|
||||
expect(putReqStatus).toEqual(403)
|
||||
expect(putReqBody).toHaveLength(1)
|
||||
expect((putReqBody as Array<object>)[0]).toEqual(invalidMemberArrayError1)
|
||||
|
||||
const { body: putReqBody2, status: putReqStatus2 } = await send('project/', 'PUT', {
|
||||
project: { ...newProjectBasis, now_proj_people: [{ com_people: existingPerson }] },
|
||||
})
|
||||
expect(putReqStatus2).toEqual(403)
|
||||
expect(putReqBody2).toHaveLength(1)
|
||||
expect((putReqBody2 as Array<object>)[0]).toEqual(invalidMemberArrayError2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import { PersonDetailsType } from '../../../../frontend/src/shared/types'
|
||||
|
||||
export const existingPerson: Omit<PersonDetailsType, 'user' | 'project_relations' | 'coordinator_relations'> = {
|
||||
initials: 'AD',
|
||||
first_name: 'adf',
|
||||
surname: 'ads',
|
||||
full_name: 'adf ads',
|
||||
format: null,
|
||||
email: 'email',
|
||||
user_id: 156,
|
||||
organization: 'organization',
|
||||
country: 'Finland',
|
||||
password_set: new Date('2024-05-22T00:00:00.000Z'),
|
||||
used_morph: null,
|
||||
used_now: null,
|
||||
used_gene: null,
|
||||
now_user_group: 'eu',
|
||||
}
|
||||
|
||||
export const existingPerson2: Omit<PersonDetailsType, 'user' | 'project_relations' | 'coordinator_relations'> = {
|
||||
initials: 'CO',
|
||||
first_name: 'cfn',
|
||||
surname: 'csn',
|
||||
full_name: 'cfn csn',
|
||||
format: null,
|
||||
email: 'email',
|
||||
user_id: 162,
|
||||
organization: 'organization',
|
||||
country: 'Finland',
|
||||
password_set: new Date('2024-05-22T00:00:00.000Z'),
|
||||
used_morph: null,
|
||||
used_now: null,
|
||||
used_gene: null,
|
||||
now_user_group: 'eu',
|
||||
}
|
||||
|
||||
export const newProjectBasis = {
|
||||
proj_name: 'LATER Database',
|
||||
proj_code: 'LATER',
|
||||
contact: 'AD',
|
||||
proj_status: 'current',
|
||||
proj_records: true,
|
||||
now_proj_people: [{ pid: 3, initials: 'AD', com_people: existingPerson }],
|
||||
references: [],
|
||||
comment: '',
|
||||
}
|
||||
|
||||
export const editedProject = {
|
||||
...newProjectBasis,
|
||||
pid: 3,
|
||||
proj_name: 'edited project name',
|
||||
proj_code: 'LATER2',
|
||||
}
|
||||
|
||||
export const newProjectBasisWithoutMemberArray = {
|
||||
proj_name: 'LATER Database',
|
||||
proj_code: 'LATER',
|
||||
contact: 'AD',
|
||||
proj_status: 'current',
|
||||
proj_records: true,
|
||||
references: [],
|
||||
comment: '',
|
||||
}
|
||||
|
||||
export const newProjectBasisWithInvalidMembers = {
|
||||
proj_name: 'LATER Database',
|
||||
proj_code: 'LATER',
|
||||
contact: 'AD',
|
||||
proj_status: 'current',
|
||||
proj_records: true,
|
||||
now_proj_people: [{ pid: 3, initials: 'AD' }],
|
||||
references: [],
|
||||
comment: '',
|
||||
}
|
||||
|
||||
export const newProjectBasisWithoutCoordinator = {
|
||||
proj_name: 'LATER Database',
|
||||
proj_code: 'LATER',
|
||||
proj_status: 'current',
|
||||
proj_records: true,
|
||||
now_proj_people: [{ pid: 3, initials: 'AD' }],
|
||||
references: [],
|
||||
comment: '',
|
||||
}
|
||||
|
||||
export const noCoordinatorError = { name: 'Coordinator', error: 'This field is required' }
|
||||
export const noMemberArrayError = { name: 'Members', error: 'This field is required' }
|
||||
export const invalidMemberArrayError1 = {
|
||||
name: 'Members',
|
||||
error: 'Project members gave the following errors: Member must have a com_people object with valid user ID',
|
||||
}
|
||||
export const invalidMemberArrayError2 = {
|
||||
name: 'Members',
|
||||
error: 'Project members gave the following errors: Member initials must be a non-empty string',
|
||||
}
|
||||
@@ -1,65 +1,76 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from '@jest/globals'
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals'
|
||||
import { login, logout, noPermError, resetDatabase, resetDatabaseTimeout, send } from '../utils'
|
||||
import { pool } from '../../utils/db'
|
||||
import { ProjectDetailsType } from '../../../../frontend/src/shared/types'
|
||||
|
||||
describe('DELETE /project/:id', () => {
|
||||
describe('Deleting project works', () => {
|
||||
beforeAll(async () => {
|
||||
await resetDatabase()
|
||||
}, resetDatabaseTimeout)
|
||||
|
||||
beforeEach(async () => {
|
||||
await login()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.end()
|
||||
})
|
||||
|
||||
it('allows admins to delete a project and related data', async () => {
|
||||
await login('testSu', 'test')
|
||||
|
||||
const createdProject = await send<{ pid: number }>('projects', 'POST', {
|
||||
projectCode: 'DEL-001',
|
||||
projectName: 'Project To Delete',
|
||||
coordinatorUserId: 163,
|
||||
memberUserIds: [167],
|
||||
})
|
||||
|
||||
const deleted = await send(`project/${createdProject.body.pid}`, 'DELETE')
|
||||
const { body: existingProject, status: getReqStatus } = await send<ProjectDetailsType>('project/14', 'GET')
|
||||
expect(getReqStatus).toEqual(200)
|
||||
expect(existingProject.pid).toEqual(14)
|
||||
|
||||
const deleted = await send(`project/${existingProject.pid}`, 'DELETE')
|
||||
expect(deleted.status).toEqual(200)
|
||||
|
||||
const fetchAfterDelete = await send(`project/${createdProject.body.pid}`, 'GET')
|
||||
const fetchAfterDelete = await send(`project/${existingProject.pid}`, 'GET')
|
||||
expect(fetchAfterDelete.status).toEqual(404)
|
||||
})
|
||||
|
||||
it('rejects deletion attempts from non-admin users', async () => {
|
||||
it('project members are not deleted along with the project', async () => {
|
||||
const { body: existingProject, status: getReqStatus } = await send<ProjectDetailsType>('project/3', 'GET')
|
||||
expect(getReqStatus).toEqual(200)
|
||||
expect(existingProject.pid).toEqual(3)
|
||||
expect(existingProject.now_proj_people[0]).toHaveProperty('com_people')
|
||||
|
||||
await login('testSu', 'test')
|
||||
const createdProject = await send<{ pid: number }>('projects', 'POST', {
|
||||
projectCode: 'DEL-002',
|
||||
projectName: 'Unauthorized Delete',
|
||||
coordinatorUserId: 163,
|
||||
const deleted = await send(`project/${existingProject.pid}`, 'DELETE')
|
||||
expect(deleted.status).toEqual(200)
|
||||
|
||||
const memberFetchAfterDelete = await send(
|
||||
`person/${existingProject.now_proj_people[0].com_people!.initials}`,
|
||||
'GET'
|
||||
)
|
||||
expect(memberFetchAfterDelete.status).toEqual(200)
|
||||
})
|
||||
|
||||
await login('testPl', 'test')
|
||||
const plDeleteAttempt = await send(`project/${createdProject.body.pid}`, 'DELETE')
|
||||
expect(plDeleteAttempt.status).toEqual(403)
|
||||
expect(plDeleteAttempt.body).toEqual(noPermError)
|
||||
it('rejects deletion attempts from non-admin users', async () => {
|
||||
const { body: existingProject, status: getReqStatus } = await send<ProjectDetailsType>('project/23', 'GET')
|
||||
expect(getReqStatus).toEqual(200)
|
||||
expect(existingProject.pid).toEqual(23)
|
||||
|
||||
await login('testEu', 'test')
|
||||
const euDeleteAttempt = await send(`project/${createdProject.body.pid}`, 'DELETE')
|
||||
const euDeleteAttempt = await send(`project/${existingProject.pid}`, 'DELETE')
|
||||
expect(euDeleteAttempt.status).toEqual(403)
|
||||
expect(euDeleteAttempt.body).toEqual(noPermError)
|
||||
|
||||
await login('testEr', 'test')
|
||||
const erDeleteAttempt = await send(`project/${createdProject.body.pid}`, 'DELETE')
|
||||
const erDeleteAttempt = await send(`project/${existingProject.pid}`, 'DELETE')
|
||||
expect(erDeleteAttempt.status).toEqual(403)
|
||||
expect(erDeleteAttempt.body).toEqual(noPermError)
|
||||
|
||||
logout()
|
||||
const anonDeleteAttempt = await send(`project/${createdProject.body.pid}`, 'DELETE')
|
||||
const anonDeleteAttempt = await send(`project/${existingProject.pid}`, 'DELETE')
|
||||
expect(anonDeleteAttempt.status).toEqual(403)
|
||||
expect(anonDeleteAttempt.body).toEqual(noPermError)
|
||||
|
||||
await login('testSu', 'test')
|
||||
const fetchAfterAttempts = await send(`project/${createdProject.body.pid}`, 'GET')
|
||||
expect(fetchAfterAttempts.status).toEqual(200)
|
||||
expect(fetchAfterAttempts.body).toHaveProperty('pid', createdProject.body.pid)
|
||||
const { body: afterAttemptsBody, status: afterAttemptsStatus } = await send<ProjectDetailsType>(
|
||||
`project/${existingProject.pid}`,
|
||||
'GET'
|
||||
)
|
||||
expect(afterAttemptsStatus).toEqual(200)
|
||||
expect(afterAttemptsBody.pid).toEqual(existingProject.pid)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals'
|
||||
import { login, logout, noPermError, resetDatabase, send } from '../utils'
|
||||
import { pool } from '../../utils/db'
|
||||
import {
|
||||
editedProject,
|
||||
existingPerson,
|
||||
existingPerson2,
|
||||
invalidMemberArrayError1,
|
||||
invalidMemberArrayError2,
|
||||
newProjectBasis,
|
||||
} from './data'
|
||||
import { ProjectDetailsType } from '../../../../frontend/src/shared/types'
|
||||
import { ValidationError } from 'express-validator'
|
||||
|
||||
let existingProject: ProjectDetailsType | null = null
|
||||
|
||||
describe('Updating project works', () => {
|
||||
beforeAll(async () => {
|
||||
await resetDatabase()
|
||||
await login()
|
||||
const { body, status: getReqStat } = await send<ProjectDetailsType>('project/3', 'GET')
|
||||
expect(getReqStat).toEqual(200)
|
||||
existingProject = body
|
||||
})
|
||||
beforeEach(async () => {
|
||||
await login()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.end()
|
||||
})
|
||||
|
||||
it('Request succeeds and returns valid number id', async () => {
|
||||
const { body: resultBody, status: updateStatus } = await send<{ pid: number }>('project/', 'PUT', {
|
||||
project: editedProject,
|
||||
})
|
||||
const { pid: updatedId } = resultBody
|
||||
|
||||
expect(typeof updatedId).toEqual('number')
|
||||
expect(updateStatus).toEqual(200)
|
||||
|
||||
const { body, status: getReqStat } = await send<ProjectDetailsType>(`project/${updatedId}`, 'GET')
|
||||
expect(getReqStat).toEqual(200)
|
||||
existingProject = body
|
||||
})
|
||||
|
||||
it('Contains correct data', () => {
|
||||
const { proj_code, proj_name } = existingProject!
|
||||
expect(proj_code).toEqual(editedProject.proj_code)
|
||||
expect(proj_name).toEqual(editedProject.proj_name)
|
||||
})
|
||||
|
||||
it('Updating fails with empty project code', async () => {
|
||||
const { body: resultBody, status: getReqStatus } = await send('project/', 'PUT', {
|
||||
project: { ...editedProject, proj_code: '' },
|
||||
})
|
||||
expect(getReqStatus).toEqual(403)
|
||||
expect(resultBody.length).toEqual(1) //There should be 1 validation error
|
||||
})
|
||||
|
||||
it('Updating fails for non-admin users', async () => {
|
||||
await login('testEr')
|
||||
const { body: resultBodyEr, status: resultStatusEr } = await send<{ pid: number }>('project/', 'PUT', {
|
||||
project: { ...editedProject, proj_code: 'CODE2' },
|
||||
})
|
||||
expect(resultBodyEr).toEqual(noPermError)
|
||||
expect(resultStatusEr).toEqual(403)
|
||||
|
||||
await login('testEu')
|
||||
const { body: resultBodyEu, status: resultStatusEu } = await send<{ pid: number }>('project/', 'PUT', {
|
||||
project: { ...editedProject, proj_code: 'CODE3' },
|
||||
})
|
||||
expect(resultBodyEu).toEqual(noPermError)
|
||||
expect(resultStatusEu).toEqual(403)
|
||||
|
||||
logout()
|
||||
const { body: resultBodyAnon, status: resultStatusAnon } = await send('project/', 'PUT', {
|
||||
project: { ...editedProject, proj_code: 'CODE4' },
|
||||
})
|
||||
expect(resultBodyAnon).toEqual(noPermError)
|
||||
expect(resultStatusAnon).toEqual(403)
|
||||
})
|
||||
|
||||
it('Adding members works', async () => {
|
||||
const { status: putResultStatus } = await send<ProjectDetailsType>('project/', 'PUT', {
|
||||
project: {
|
||||
...editedProject,
|
||||
now_proj_people: [
|
||||
...existingProject!.now_proj_people,
|
||||
{ initials: existingPerson2.initials, pid: 3, com_people: existingPerson2 },
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(putResultStatus).toEqual(200)
|
||||
|
||||
const { body: getResultBody, status: getResultStatus } = await send<ProjectDetailsType>('project/3', 'GET')
|
||||
expect(getResultStatus).toEqual(200)
|
||||
expect(getResultBody.now_proj_people).toHaveLength(2)
|
||||
expect(getResultBody.now_proj_people[0].initials).toEqual(existingPerson.initials)
|
||||
expect(getResultBody.now_proj_people[1].initials).toEqual(existingPerson2.initials)
|
||||
existingProject = getResultBody
|
||||
})
|
||||
|
||||
it('Removing members works', async () => {
|
||||
expect(existingProject!.now_proj_people).toHaveLength(2) // set in previous test
|
||||
const { status: putResultStatus } = await send<ProjectDetailsType>('project/', 'PUT', {
|
||||
project: { ...editedProject, now_proj_people: newProjectBasis.now_proj_people },
|
||||
})
|
||||
expect(putResultStatus).toEqual(200)
|
||||
|
||||
const { body: getResultBody, status: getResultStatus } = await send<ProjectDetailsType>('project/3', 'GET')
|
||||
expect(getResultStatus).toEqual(200)
|
||||
expect(getResultBody.now_proj_people).toHaveLength(1)
|
||||
expect(getResultBody.now_proj_people[0].initials).toEqual(existingPerson.initials)
|
||||
|
||||
const { status: putResultStatus2 } = await send<ProjectDetailsType>('project/', 'PUT', {
|
||||
project: { ...editedProject, now_proj_people: [] },
|
||||
})
|
||||
expect(putResultStatus2).toEqual(200)
|
||||
|
||||
const { body: getResultBody2, status: getResultStatus2 } = await send<ProjectDetailsType>('project/3', 'GET')
|
||||
expect(getResultStatus2).toEqual(200)
|
||||
expect(getResultBody2.now_proj_people).toEqual([])
|
||||
})
|
||||
|
||||
it('Adding members without required fields does not work', async () => {
|
||||
const { body: putResultBody, status: putResultStatus } = await send('project/', 'PUT', {
|
||||
project: {
|
||||
...editedProject,
|
||||
now_proj_people: [{ initials: existingPerson2.initials }],
|
||||
},
|
||||
})
|
||||
expect(putResultStatus).toEqual(403)
|
||||
expect(putResultBody).toHaveLength(1)
|
||||
expect((putResultBody as Array<ValidationError>)[0]).toEqual(invalidMemberArrayError1)
|
||||
|
||||
const { body: putResultBody2, status: putResultStatus2 } = await send('project/', 'PUT', {
|
||||
project: {
|
||||
...editedProject,
|
||||
now_proj_people: [{ com_people: existingPerson2 }],
|
||||
},
|
||||
})
|
||||
expect(putResultStatus2).toEqual(403)
|
||||
expect(putResultBody2).toHaveLength(1)
|
||||
expect((putResultBody2 as Array<ValidationError>)[0]).toEqual(invalidMemberArrayError2)
|
||||
})
|
||||
})
|
||||
@@ -1,88 +0,0 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from '@jest/globals'
|
||||
import { login, logout, noPermError, resetDatabase, resetDatabaseTimeout, send } from '../utils'
|
||||
import { pool } from '../../utils/db'
|
||||
|
||||
describe('POST /projects', () => {
|
||||
beforeAll(async () => {
|
||||
await resetDatabase()
|
||||
}, resetDatabaseTimeout)
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.end()
|
||||
})
|
||||
|
||||
it('creates a project with members', async () => {
|
||||
await login('testSu', 'test')
|
||||
|
||||
const result = await send<{ pid: number; contact: string; now_proj_people: Array<{ initials: string }> }>(
|
||||
'projects',
|
||||
'POST',
|
||||
{
|
||||
projectCode: 'PRJ-001',
|
||||
projectName: 'Integration Test Project',
|
||||
coordinatorUserId: 163,
|
||||
projectStatus: 'current',
|
||||
recordStatus: true,
|
||||
memberUserIds: [167],
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.status).toEqual(201)
|
||||
expect(result.body.contact).toEqual('TEST-SU')
|
||||
expect(result.body.now_proj_people).toEqual([{ initials: 'TEST-PL', pid: result.body.pid }])
|
||||
})
|
||||
|
||||
it('returns validation error when coordinator is missing', async () => {
|
||||
await login('testSu', 'test')
|
||||
|
||||
const result = await send('projects', 'POST', {
|
||||
projectCode: 'PRJ-002',
|
||||
projectName: 'Missing Coordinator',
|
||||
projectStatus: 'planned',
|
||||
recordStatus: false,
|
||||
})
|
||||
|
||||
expect(result.status).toEqual(400)
|
||||
})
|
||||
|
||||
it('rejects unknown member ids', async () => {
|
||||
await login('testSu', 'test')
|
||||
|
||||
const result = await send('projects', 'POST', {
|
||||
projectCode: 'PRJ-003',
|
||||
projectName: 'Unknown Member',
|
||||
coordinatorUserId: 163,
|
||||
memberUserIds: [99999],
|
||||
})
|
||||
|
||||
expect(result.status).toEqual(400)
|
||||
})
|
||||
|
||||
it('denies non-admin users', async () => {
|
||||
const unauthorizedProject = {
|
||||
projectCode: 'PRJ-004',
|
||||
projectName: 'Unauthorized Create',
|
||||
coordinatorUserId: 163,
|
||||
}
|
||||
|
||||
await login('testPl', 'test')
|
||||
const plResult = await send('projects', 'POST', unauthorizedProject)
|
||||
expect(plResult.status).toEqual(403)
|
||||
expect(plResult.body).toEqual(noPermError)
|
||||
|
||||
await login('testEu', 'test')
|
||||
const euResult = await send('projects', 'POST', unauthorizedProject)
|
||||
expect(euResult.status).toEqual(403)
|
||||
expect(euResult.body).toEqual(noPermError)
|
||||
|
||||
await login('testEr', 'test')
|
||||
const erResult = await send('projects', 'POST', unauthorizedProject)
|
||||
expect(erResult.status).toEqual(403)
|
||||
expect(erResult.body).toEqual(noPermError)
|
||||
|
||||
logout()
|
||||
const anonResult = await send('projects', 'POST', unauthorizedProject)
|
||||
expect(anonResult.status).toEqual(403)
|
||||
expect(anonResult.body).toEqual(noPermError)
|
||||
})
|
||||
})
|
||||
@@ -1,102 +0,0 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals'
|
||||
import { login, logout, noPermError, resetDatabase, resetDatabaseTimeout, send } from '../utils'
|
||||
import { pool } from '../../utils/db'
|
||||
|
||||
describe('PUT /projects/:id', () => {
|
||||
beforeAll(async () => {
|
||||
await resetDatabase()
|
||||
}, resetDatabaseTimeout)
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDatabase()
|
||||
await login('testSu', 'test')
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.end()
|
||||
})
|
||||
|
||||
it('updates coordinator and members', async () => {
|
||||
const createResult = await send<{ pid: number }>('projects', 'POST', {
|
||||
projectCode: 'PRJ-UPDATE',
|
||||
projectName: 'Project To Update',
|
||||
coordinatorUserId: 163,
|
||||
memberUserIds: [167],
|
||||
})
|
||||
expect(createResult.status).toEqual(201)
|
||||
|
||||
const result = await send<{
|
||||
pid: number
|
||||
contact: string
|
||||
now_proj_people: Array<{ initials: string; pid: number }>
|
||||
proj_name: string
|
||||
proj_code: string
|
||||
}>(`projects/${createResult.body.pid}`, 'PUT', {
|
||||
projectCode: 'PRJ-UPD01',
|
||||
projectName: 'Updated Project',
|
||||
coordinatorUserId: 167,
|
||||
memberUserIds: [163],
|
||||
})
|
||||
|
||||
expect(result.status).toEqual(200)
|
||||
expect(result.body.contact).toEqual('TEST-PL')
|
||||
expect(result.body.proj_code).toEqual('PRJ-UPD01')
|
||||
expect(result.body.proj_name).toEqual('Updated Project')
|
||||
expect(result.body.now_proj_people).toEqual([{ initials: 'TEST-SU', pid: result.body.pid }])
|
||||
})
|
||||
|
||||
it('returns validation error for invalid members', async () => {
|
||||
const createResult = await send<{ pid: number }>('projects', 'POST', {
|
||||
projectCode: 'PRJ-INV1',
|
||||
projectName: 'Project Invalid Members',
|
||||
coordinatorUserId: 163,
|
||||
})
|
||||
expect(createResult.status).toEqual(201)
|
||||
|
||||
const result = await send(`projects/${createResult.body.pid}`, 'PUT', {
|
||||
memberUserIds: ['not-a-number'],
|
||||
})
|
||||
|
||||
expect(result.status).toEqual(400)
|
||||
})
|
||||
|
||||
it('returns validation error for too-long project code', async () => {
|
||||
const createResult = await send<{ pid: number }>('projects', 'POST', {
|
||||
projectCode: 'PRJ-LONG1',
|
||||
projectName: 'Project Long Code',
|
||||
coordinatorUserId: 163,
|
||||
})
|
||||
expect(createResult.status).toEqual(201)
|
||||
|
||||
const result = await send(`projects/${createResult.body.pid}`, 'PUT', {
|
||||
projectCode: 'PRJ-UPDATED',
|
||||
})
|
||||
|
||||
expect(result.status).toEqual(400)
|
||||
expect(result.body).toEqual({ message: 'Project code must be at most 10 characters' })
|
||||
})
|
||||
|
||||
it('denies non-admin users', async () => {
|
||||
const unauthorizedUpdate = { projectName: 'Unauthorized Update' }
|
||||
|
||||
await login('testPl', 'test')
|
||||
const plResult = await send('projects/1', 'PUT', unauthorizedUpdate)
|
||||
expect(plResult.status).toEqual(403)
|
||||
expect(plResult.body).toEqual(noPermError)
|
||||
|
||||
await login('testEu', 'test')
|
||||
const euResult = await send('projects/1', 'PUT', unauthorizedUpdate)
|
||||
expect(euResult.status).toEqual(403)
|
||||
expect(euResult.body).toEqual(noPermError)
|
||||
|
||||
await login('testEr', 'test')
|
||||
const erResult = await send('projects/1', 'PUT', unauthorizedUpdate)
|
||||
expect(erResult.status).toEqual(403)
|
||||
expect(erResult.body).toEqual(noPermError)
|
||||
|
||||
logout()
|
||||
const anonResult = await send('projects/1', 'PUT', unauthorizedUpdate)
|
||||
expect(anonResult.status).toEqual(403)
|
||||
expect(anonResult.body).toEqual(noPermError)
|
||||
})
|
||||
})
|
||||
@@ -21,9 +21,12 @@ export const send = async <T extends Record<string, unknown> | Array<Record<stri
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('authorization', `bearer ${token ?? ''}`)
|
||||
|
||||
// TODO remove this when fixBigInt is refactored. Also lint-ignore is then not needed
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
response.body = response.text ? JSON.parse(response.text) : response.body
|
||||
} catch {
|
||||
response.body = response.text
|
||||
}
|
||||
break
|
||||
|
||||
case 'POST':
|
||||
|
||||
@@ -11,7 +11,6 @@ import collectingMethodValuesRouter from './routes/collectingMethodValues'
|
||||
import regionRouter from './routes/region'
|
||||
import personRouter from './routes/person'
|
||||
import projectRouter from './routes/project'
|
||||
import projectsRouter from './routes/projects'
|
||||
import speciesRouter from './routes/species'
|
||||
import localitySpeciesRouter from './routes/localitySpecies'
|
||||
import refreshTokenRouter from './routes/refresh'
|
||||
@@ -72,7 +71,6 @@ app.use('/time-bound', requireOneOf([Role.Admin, Role.EditUnrestricted]), timeBo
|
||||
app.use('/region', requireOneOf([Role.Admin]), regionRouter)
|
||||
app.use('/person', personRouter)
|
||||
app.use('/project', projectRouter)
|
||||
app.use('/projects', requireOneOf([Role.Admin]), projectsRouter)
|
||||
app.use('/museum', museumRouter)
|
||||
app.use('/sedimentary-structure', sedimentaryStructureRouter)
|
||||
app.use('/collecting-method-values', collectingMethodValuesRouter)
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Request, Response } from 'express'
|
||||
import { createProject, CreateProjectInput, updateProject, UpdateProjectInput } from '../services/projectsService'
|
||||
|
||||
export const createProjectHandler = async (req: Request<object, object, CreateProjectInput>, res: Response) => {
|
||||
const createdProject = await createProject(req.body)
|
||||
|
||||
return res.status(201).json(createdProject)
|
||||
}
|
||||
|
||||
export const updateProjectHandler = async (req: Request<{ id: string }, object, UpdateProjectInput>, res: Response) => {
|
||||
const updatedProject = await updateProject(Number(req.params.id), req.body)
|
||||
|
||||
return res.status(200).json(updatedProject)
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Router } from 'express'
|
||||
import { deleteProject, getAllProjects, getProjectDetails } from '../services/project'
|
||||
import { Request, Router } from 'express'
|
||||
import { deleteProject, getAllProjects, getProjectDetails, validateEntireProject } from '../services/project'
|
||||
import { requireOneOf } from '../middlewares/authorizer'
|
||||
import { Role } from '../../../frontend/src/shared/types'
|
||||
import { EditDataType, EditMetaData, ProjectDetailsType, Role } from '../../../frontend/src/shared/types'
|
||||
import { writeProject } from '../services/write/project'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -17,6 +18,24 @@ router.get('/:id', requireOneOf([Role.Admin]), async (req, res) => {
|
||||
return res.status(200).send(project)
|
||||
})
|
||||
|
||||
router.put(
|
||||
'/',
|
||||
requireOneOf([Role.Admin]),
|
||||
async (req: Request<object, object, { project: EditDataType<ProjectDetailsType> & EditMetaData }>, res) => {
|
||||
try {
|
||||
const { ...editedProject } = req.body.project
|
||||
const validationErrors = validateEntireProject({ ...editedProject })
|
||||
if (validationErrors.length > 0) {
|
||||
return res.status(403).send(validationErrors)
|
||||
}
|
||||
const pid = await writeProject(editedProject)
|
||||
return res.status(200).send({ pid })
|
||||
} catch (error) {
|
||||
return res.status(500).send({ message: 'Failed to write project' })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
router.delete('/:id', requireOneOf([Role.Admin]), async (req, res) => {
|
||||
const id = parseInt(req.params.id)
|
||||
const deleted = await deleteProject(id)
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Router } from 'express'
|
||||
import { createProjectHandler, updateProjectHandler } from '../controllers/projectsController'
|
||||
import { requireOneOf } from '../middlewares/authorizer'
|
||||
import { Role } from '../../../frontend/src/shared/types'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.post('/', requireOneOf([Role.Admin]), createProjectHandler)
|
||||
router.put('/:id', requireOneOf([Role.Admin]), updateProjectHandler)
|
||||
|
||||
export default router
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Role, User } from '../../../frontend/src/shared/types'
|
||||
import { EditDataType, EditMetaData, ProjectDetailsType, Role, User } from '../../../frontend/src/shared/types'
|
||||
import { validateProjectFields } from '../../../frontend/src/shared/validators/project'
|
||||
import { ValidationObject } from '../../../frontend/src/shared/validators/validator'
|
||||
import { nowDb } from '../utils/db'
|
||||
|
||||
export const getAllProjects = async (user?: User) => {
|
||||
@@ -20,7 +22,6 @@ export const getAllProjects = async (user?: User) => {
|
||||
|
||||
export const getProjectDetails = async (id: number) => {
|
||||
// TODO: Check if user has access
|
||||
|
||||
const result = await nowDb.now_proj.findUnique({
|
||||
where: { pid: id },
|
||||
include: {
|
||||
@@ -61,3 +62,8 @@ export const deleteProject = async (projectId: number) => {
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export const validateEntireProject = (editedFields: EditDataType<ProjectDetailsType> & EditMetaData) => {
|
||||
const errors: ValidationObject[] = validateProjectFields(editedFields)
|
||||
return errors
|
||||
}
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
import { type ProjectStatus } from '../constants/status'
|
||||
import { nowDb } from '../utils/db'
|
||||
import {
|
||||
ensureNonEmptyString,
|
||||
ensureValidMemberIds,
|
||||
ensureValidProjectStatus,
|
||||
ensureValidRecordStatus,
|
||||
ensureValidUserId,
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
} from '../validators/projectsValidator'
|
||||
|
||||
export type CreateProjectInput = {
|
||||
projectCode: string
|
||||
projectName: string
|
||||
coordinatorUserId: number
|
||||
projectStatus?: ProjectStatus | null
|
||||
recordStatus?: boolean | null
|
||||
memberUserIds?: number[]
|
||||
}
|
||||
|
||||
export type UpdateProjectInput = {
|
||||
projectCode?: string
|
||||
projectName?: string
|
||||
coordinatorUserId?: number
|
||||
projectStatus?: ProjectStatus | null
|
||||
recordStatus?: boolean | null
|
||||
memberUserIds?: number[]
|
||||
}
|
||||
|
||||
const ensureValidProjectStatusValue = (value: unknown) => ensureValidProjectStatus(value)
|
||||
const ensureValidRecordStatusValue = (value: unknown) => ensureValidRecordStatus(value)
|
||||
|
||||
const loadMembersByIds = async (memberIds: number[]) => {
|
||||
if (!memberIds.length) return [] as Array<{ initials: string; user_id: number }>
|
||||
|
||||
const members = await nowDb.com_people.findMany({
|
||||
select: { initials: true, user_id: true },
|
||||
where: { user_id: { in: memberIds } },
|
||||
})
|
||||
|
||||
if (members.length !== memberIds.length) {
|
||||
throw new ValidationError('One or more project members do not exist')
|
||||
}
|
||||
|
||||
return members
|
||||
}
|
||||
|
||||
const loadCoordinatorById = async (coordinatorId: number) => {
|
||||
const coordinator = await nowDb.com_people.findFirst({
|
||||
select: { initials: true },
|
||||
where: { user_id: coordinatorId },
|
||||
})
|
||||
|
||||
if (!coordinator) {
|
||||
throw new ValidationError('Coordinator user does not exist')
|
||||
}
|
||||
|
||||
return coordinator
|
||||
}
|
||||
|
||||
export const createProject = async ({
|
||||
projectCode,
|
||||
projectName,
|
||||
coordinatorUserId,
|
||||
projectStatus,
|
||||
recordStatus,
|
||||
memberUserIds,
|
||||
}: CreateProjectInput) => {
|
||||
const proj_code = ensureNonEmptyString(projectCode, 'Project code', 10)
|
||||
const proj_name = ensureNonEmptyString(projectName, 'Project name', 80)
|
||||
const coordinatorId = ensureValidUserId(coordinatorUserId, 'Coordinator user ID')
|
||||
const uniqueMemberIds = ensureValidMemberIds(memberUserIds)
|
||||
const validProjectStatus = ensureValidProjectStatusValue(projectStatus)
|
||||
const validRecordStatus = ensureValidRecordStatusValue(recordStatus)
|
||||
|
||||
const coordinator = await loadCoordinatorById(coordinatorId)
|
||||
const members = await loadMembersByIds(uniqueMemberIds)
|
||||
|
||||
const project = await nowDb.$transaction(async prisma => {
|
||||
const createdProject = await prisma.now_proj.create({
|
||||
data: {
|
||||
proj_code,
|
||||
proj_name,
|
||||
proj_status: validProjectStatus,
|
||||
proj_records: validRecordStatus,
|
||||
contact: coordinator.initials,
|
||||
now_proj_people: members.length
|
||||
? {
|
||||
create: members.map(member => ({ initials: member.initials })),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
include: { now_proj_people: true },
|
||||
})
|
||||
|
||||
return createdProject
|
||||
})
|
||||
|
||||
return project
|
||||
}
|
||||
|
||||
export const updateProject = async (
|
||||
projectId: number,
|
||||
{ projectCode, projectName, coordinatorUserId, projectStatus, recordStatus, memberUserIds }: UpdateProjectInput
|
||||
) => {
|
||||
const existingProject = await nowDb.now_proj.findUnique({
|
||||
where: { pid: projectId },
|
||||
include: { now_proj_people: true },
|
||||
})
|
||||
|
||||
if (!existingProject) {
|
||||
throw new NotFoundError('Project not found')
|
||||
}
|
||||
|
||||
const proj_code = projectCode ? ensureNonEmptyString(projectCode, 'Project code', 10) : existingProject.proj_code
|
||||
const proj_name = projectName ? ensureNonEmptyString(projectName, 'Project name', 80) : existingProject.proj_name
|
||||
const validProjectStatus = ensureValidProjectStatusValue(projectStatus ?? existingProject.proj_status)
|
||||
const validRecordStatus = ensureValidRecordStatusValue(recordStatus ?? existingProject.proj_records)
|
||||
|
||||
const coordinatorInitials =
|
||||
coordinatorUserId !== undefined
|
||||
? (await loadCoordinatorById(ensureValidUserId(coordinatorUserId, 'Coordinator user ID'))).initials
|
||||
: existingProject.contact
|
||||
|
||||
const memberIdsToUpdate = memberUserIds !== undefined ? ensureValidMemberIds(memberUserIds) : null
|
||||
const members = memberIdsToUpdate ? await loadMembersByIds(memberIdsToUpdate) : null
|
||||
|
||||
const project = await nowDb.$transaction(async prisma => {
|
||||
const updatedProject = await prisma.now_proj.update({
|
||||
where: { pid: projectId },
|
||||
data: {
|
||||
proj_code,
|
||||
proj_name,
|
||||
proj_status: validProjectStatus,
|
||||
proj_records: validRecordStatus,
|
||||
contact: coordinatorInitials,
|
||||
},
|
||||
})
|
||||
|
||||
if (members !== null && memberIdsToUpdate !== null) {
|
||||
await prisma.now_proj_people.deleteMany({ where: { pid: projectId } })
|
||||
if (members.length) {
|
||||
await prisma.now_proj_people.createMany({
|
||||
data: members.map(member => ({ pid: projectId, initials: member.initials })),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return prisma.now_proj.findUnique({ where: { pid: updatedProject.pid }, include: { now_proj_people: true } })
|
||||
})
|
||||
|
||||
return project
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { EditDataType, ProjectDetailsType } from '../../../../frontend/src/shared/types'
|
||||
import Prisma from '../../../prisma/generated/now_test_client'
|
||||
import { getFieldsOfTables, nowDb } from '../../utils/db'
|
||||
import { getProjectDetails } from '../project'
|
||||
import { filterAllowedKeys, fixRadioSelection } from './writeOperations/utils'
|
||||
|
||||
const loadMembersByIds = async (memberIds: number[]) => {
|
||||
if (!memberIds.length) return [] as Array<{ initials: string; user_id: number }>
|
||||
|
||||
const members = await nowDb.com_people.findMany({
|
||||
select: { initials: true, user_id: true },
|
||||
where: { user_id: { in: memberIds } },
|
||||
})
|
||||
|
||||
if (members.length !== memberIds.length) {
|
||||
throw new Error('One or more project members do not exist')
|
||||
}
|
||||
|
||||
return members
|
||||
}
|
||||
|
||||
export const writeProject = async (project: EditDataType<ProjectDetailsType>) => {
|
||||
const allowedColumns = getFieldsOfTables(['now_proj'])
|
||||
const filteredProject = filterAllowedKeys(project, allowedColumns) as Prisma.now_proj
|
||||
let projectId: number
|
||||
|
||||
const contactPerson = await nowDb.com_people.findFirst({ where: { initials: project.contact } })
|
||||
if (!contactPerson) {
|
||||
throw new Error('Contact does not exist')
|
||||
}
|
||||
|
||||
const memberUserIds = project.now_proj_people
|
||||
.filter(member => member.rowState !== 'removed')
|
||||
.map(member => member.com_people!.user_id!)
|
||||
const members = await loadMembersByIds(memberUserIds)
|
||||
|
||||
const existingProject = filteredProject.pid ? await getProjectDetails(filteredProject.pid) : null
|
||||
if (!existingProject) {
|
||||
const newProject = await nowDb.now_proj.create({
|
||||
data: {
|
||||
...filteredProject,
|
||||
proj_records: fixRadioSelection(filteredProject.proj_records),
|
||||
now_proj_people:
|
||||
members.length > 0
|
||||
? {
|
||||
create: members.map(member => ({ initials: member.initials })),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
include: { now_proj_people: true },
|
||||
})
|
||||
projectId = newProject.pid
|
||||
} else {
|
||||
await nowDb.$transaction(async prisma => {
|
||||
await prisma.now_proj.update({
|
||||
where: { pid: filteredProject.pid },
|
||||
data: {
|
||||
...filteredProject,
|
||||
proj_records: fixRadioSelection(filteredProject.proj_records),
|
||||
},
|
||||
})
|
||||
|
||||
if (existingProject) {
|
||||
// removes all members, then adds back ones that are not marked as removed
|
||||
if (Array.isArray(members)) {
|
||||
await prisma.now_proj_people.deleteMany({ where: { pid: filteredProject.pid } })
|
||||
if (members.length > 0) {
|
||||
await prisma.now_proj_people.createMany({
|
||||
data: members.map(member => ({ pid: filteredProject.pid, initials: member.initials })),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
projectId = filteredProject.pid
|
||||
}
|
||||
|
||||
return projectId
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { describe, expect, it } from '@jest/globals'
|
||||
import { ensureValidMemberIds, ensureValidUserId, ValidationError } from '../../validators/projectsValidator'
|
||||
|
||||
describe('projectsValidator', () => {
|
||||
it('validates numeric coordinator id', () => {
|
||||
expect(ensureValidUserId(5, 'Coordinator user ID')).toEqual(5)
|
||||
})
|
||||
|
||||
it('throws on invalid coordinator id', () => {
|
||||
expect(() => ensureValidUserId('abc', 'Coordinator user ID')).toThrow(ValidationError)
|
||||
})
|
||||
|
||||
it('accepts unique numeric member ids', () => {
|
||||
expect(ensureValidMemberIds([1, 1, 2])).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('rejects non-array member ids', () => {
|
||||
expect(() => ensureValidMemberIds('1,2,3')).toThrow(ValidationError)
|
||||
})
|
||||
|
||||
it('rejects member ids containing non-numbers', () => {
|
||||
expect(() => ensureValidMemberIds([1, 'two', 3])).toThrow(ValidationError)
|
||||
})
|
||||
})
|
||||
@@ -1,75 +0,0 @@
|
||||
import { PROJECT_STATUSES, type ProjectStatus, RECORD_STATUS_VALUES } from '../constants/status'
|
||||
|
||||
export class ValidationError extends Error {
|
||||
declare status: number
|
||||
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.status = 400
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends Error {
|
||||
declare status: number
|
||||
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.status = 404
|
||||
}
|
||||
}
|
||||
|
||||
export const ensureNonEmptyString = (value: unknown, fieldName: string, maxLength?: number) => {
|
||||
if (typeof value !== 'string' || value.trim() === '') {
|
||||
throw new ValidationError(`${fieldName} is required`)
|
||||
}
|
||||
|
||||
const trimmedValue = value.trim()
|
||||
|
||||
if (maxLength !== undefined && trimmedValue.length > maxLength) {
|
||||
throw new ValidationError(`${fieldName} must be at most ${maxLength} characters`)
|
||||
}
|
||||
|
||||
return trimmedValue
|
||||
}
|
||||
|
||||
export const ensureValidUserId = (value: unknown, fieldName: string) => {
|
||||
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
||||
throw new ValidationError(`${fieldName} must be a valid number`)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
export const ensureValidMemberIds = (memberUserIds: unknown) => {
|
||||
if (memberUserIds === undefined) return [] as number[]
|
||||
|
||||
if (!Array.isArray(memberUserIds)) {
|
||||
throw new ValidationError('Project members must be an array of user IDs')
|
||||
}
|
||||
|
||||
const numericIds = memberUserIds.filter((id): id is number => typeof id === 'number' && Number.isInteger(id))
|
||||
|
||||
if (numericIds.length !== memberUserIds.length) {
|
||||
throw new ValidationError('Project members must be an array of user IDs')
|
||||
}
|
||||
|
||||
return Array.from(new Set(numericIds))
|
||||
}
|
||||
|
||||
export const ensureValidProjectStatus = (value: unknown): ProjectStatus | null => {
|
||||
if (value === undefined || value === null || value === '') return null
|
||||
if (typeof value !== 'string' || !PROJECT_STATUSES.includes(value as ProjectStatus)) {
|
||||
throw new ValidationError('Project status is invalid')
|
||||
}
|
||||
|
||||
return value as ProjectStatus
|
||||
}
|
||||
|
||||
export const ensureValidRecordStatus = (value: unknown): boolean | null => {
|
||||
if (value === undefined || value === null || value === '') return null
|
||||
if (typeof value !== 'boolean' || !RECORD_STATUS_VALUES.includes(value)) {
|
||||
throw new ValidationError('Record status is invalid')
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
@@ -162,7 +162,10 @@ describe('Broadly test what different user rights see', () => {
|
||||
it('Direct routes to protected views do not work', () => {
|
||||
cy.pageForbidden('/region')
|
||||
cy.pageForbidden('/time-bound')
|
||||
cy.pageForbidden('/project')
|
||||
})
|
||||
|
||||
it('Direct route to project details does not work', () => {
|
||||
cy.pageForbidden('/project/1')
|
||||
})
|
||||
|
||||
it('Direct routes to create new pages do not work', () => {
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import type { ProjectFormValues } from '@/components/Project/ProjectForm'
|
||||
import type { UserOption } from '@/hooks/useUsersApi'
|
||||
import type { EditDataType, ProjectDetailsType, ProjectPeople, RowState } from '@/shared/types'
|
||||
import type { UpdateProjectPayload } from '@/redux/projectReducer'
|
||||
|
||||
const findUserIdByInitials = (users: UserOption[], initials: string | null): number | null => {
|
||||
if (!initials) return null
|
||||
const match = users.find(user => user.initials === initials)
|
||||
return match?.userId ?? null
|
||||
}
|
||||
|
||||
const mapMembersToUserIds = (project: ProjectDetailsType, users: UserOption[]): number[] =>
|
||||
Array.from(
|
||||
new Set(
|
||||
project.now_proj_people
|
||||
.map(member => {
|
||||
const userIdFromPeople = (member.com_people as { user_id?: number } | undefined)?.user_id
|
||||
const userIdFromRelation = member.com_people?.user?.user_id
|
||||
if (typeof userIdFromPeople === 'number') return userIdFromPeople
|
||||
if (typeof userIdFromRelation === 'number') return userIdFromRelation
|
||||
return findUserIdByInitials(users, member.initials)
|
||||
})
|
||||
.filter((id): id is number => typeof id === 'number')
|
||||
)
|
||||
)
|
||||
|
||||
export const projectToFormValues = (project: ProjectDetailsType, users: UserOption[]): ProjectFormValues => {
|
||||
const coordinatorUserId = findUserIdByInitials(users, project.contact)
|
||||
const memberUserIds = mapMembersToUserIds(project, users).filter(id => id !== coordinatorUserId)
|
||||
|
||||
return {
|
||||
projectCode: project.proj_code ?? '',
|
||||
projectName: project.proj_name ?? '',
|
||||
coordinatorUserId,
|
||||
projectStatus: project.proj_status ?? '',
|
||||
recordStatus: project.proj_records ?? '',
|
||||
memberUserIds,
|
||||
}
|
||||
}
|
||||
|
||||
const isRemoved = (member: EditDataType<ProjectPeople>) => {
|
||||
const state = (member as EditDataType<ProjectPeople> & { rowState?: RowState }).rowState
|
||||
return state === 'removed' || state === 'cancelled'
|
||||
}
|
||||
|
||||
export const mapProjectEditDataToUpdatePayload = (
|
||||
editData: EditDataType<ProjectDetailsType>,
|
||||
users: UserOption[]
|
||||
): UpdateProjectPayload | null => {
|
||||
const coordinatorUserId = findUserIdByInitials(users, editData.contact ?? null)
|
||||
if (!coordinatorUserId || typeof editData.pid !== 'number') return null
|
||||
|
||||
const memberUserIds = Array.from(
|
||||
new Set(
|
||||
(editData.now_proj_people ?? [])
|
||||
.filter(member => !isRemoved(member))
|
||||
.map(member => {
|
||||
const userIdFromPeople = (member.com_people as { user_id?: number } | undefined)?.user_id
|
||||
const userIdFromRelation = member.com_people?.user?.user_id
|
||||
if (typeof userIdFromPeople === 'number') return userIdFromPeople
|
||||
if (typeof userIdFromRelation === 'number') return userIdFromRelation
|
||||
return findUserIdByInitials(users, member.initials ?? null)
|
||||
})
|
||||
.filter((id): id is number => typeof id === 'number')
|
||||
)
|
||||
)
|
||||
|
||||
const normalizeRecordStatus = (value: EditDataType<ProjectDetailsType>['proj_records']) => {
|
||||
if (typeof value === 'string') {
|
||||
const normalizedValue = (value as string).trim().toLowerCase()
|
||||
if (normalizedValue === 'true') return true
|
||||
if (normalizedValue === 'false') return false
|
||||
return normalizedValue === 'true'
|
||||
}
|
||||
if (typeof value === 'boolean') return value
|
||||
return Boolean(value)
|
||||
}
|
||||
|
||||
return {
|
||||
pid: editData.pid,
|
||||
projectCode: (editData.proj_code ?? '').trim(),
|
||||
projectName: (editData.proj_name ?? '').trim(),
|
||||
coordinatorUserId,
|
||||
projectStatus: (editData.proj_status ?? '').toString(),
|
||||
recordStatus: normalizeRecordStatus(editData.proj_records),
|
||||
memberUserIds,
|
||||
}
|
||||
}
|
||||
@@ -26,13 +26,13 @@ export const NavBar = () => {
|
||||
{ title: 'Time Units', url: '/time-unit' },
|
||||
{ title: 'Time Bounds', url: '/time-bound', allowedRoles: [Role.Admin, Role.EditUnrestricted] },
|
||||
{ title: 'Museums', url: '/museum' },
|
||||
{ title: 'Projects', url: '/project' },
|
||||
{
|
||||
title: 'Admin',
|
||||
url: '/admin',
|
||||
allowedRoles: [Role.Admin],
|
||||
children: [
|
||||
{ title: 'Regions', url: '/region' },
|
||||
{ title: 'Projects', url: '/project' },
|
||||
{ title: 'People', url: '/person' },
|
||||
{ title: 'Email', url: '/email' },
|
||||
{ title: 'Merge Species', url: '/admin/merge-species' },
|
||||
|
||||
@@ -1,30 +1,33 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { CircularProgress, Stack, Typography } from '@mui/material'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { PermissionDenied } from '@/components/PermissionDenied'
|
||||
import { ProjectForm, type ProjectFormValues } from '@/components/Project/ProjectForm'
|
||||
import { UnsavedChangesProvider } from '@/components/UnsavedChangesProvider'
|
||||
import { useNotify } from '@/hooks/notification'
|
||||
import { useUsersApi } from '@/hooks/useUsersApi'
|
||||
import { useProjectsApi } from '@/hooks/useProjectsApi'
|
||||
import { useUser } from '@/hooks/user'
|
||||
import { Role } from '@/shared/types'
|
||||
import type { FetchBaseQueryError } from '@reduxjs/toolkit/query'
|
||||
import { useGetAllPersonsQuery } from '@/redux/personReducer'
|
||||
import { useEditProjectMutation } from '@/redux/projectReducer'
|
||||
import { Role, ValidationErrors } from '@/shared/types'
|
||||
|
||||
export const ProjectCreatePage = () => {
|
||||
const user = useUser()
|
||||
const navigate = useNavigate()
|
||||
const { notify } = useNotify()
|
||||
const { users, isLoading: personsLoading, isError: personsError } = useUsersApi()
|
||||
const { createProject, isSubmitting } = useProjectsApi()
|
||||
const { data: personQueryData, isLoading: personQueryIsLoading } = useGetAllPersonsQuery()
|
||||
const [editProject] = useEditProjectMutation()
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'New project'
|
||||
}, [])
|
||||
|
||||
const userOptions = useMemo(() => users, [users])
|
||||
if (personQueryIsLoading) {
|
||||
return <CircularProgress />
|
||||
}
|
||||
|
||||
if (!user.token) {
|
||||
return (
|
||||
@@ -66,31 +69,37 @@ export const ProjectCreatePage = () => {
|
||||
|
||||
const handleSubmit = async (values: ProjectFormValues) => {
|
||||
setSubmitError(null)
|
||||
|
||||
const coordinator = users.find(user => user.userId === values.coordinatorUserId!)
|
||||
if (coordinator === undefined) {
|
||||
setSubmitError('Selected coordinator is not a valid user')
|
||||
}
|
||||
|
||||
const filteredPersons = personQueryData!.filter(
|
||||
person => person.user && values.memberUserIds.includes(person.user.user_id)
|
||||
)
|
||||
const nowProjPeople = filteredPersons.map(person => {
|
||||
return {
|
||||
initials: person.initials,
|
||||
com_people: person,
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const createdProject = await createProject({
|
||||
projectCode: values.projectCode.trim(),
|
||||
projectName: values.projectName.trim(),
|
||||
coordinatorUserId: values.coordinatorUserId!,
|
||||
projectStatus: values.projectStatus,
|
||||
recordStatus: values.recordStatus as boolean,
|
||||
memberUserIds: values.memberUserIds.length ? values.memberUserIds : undefined,
|
||||
const createdProject = await editProject({
|
||||
proj_code: values.projectCode.trim(),
|
||||
proj_name: values.projectName.trim(),
|
||||
contact: coordinator!.initials,
|
||||
proj_status: values.projectStatus,
|
||||
proj_records: values.recordStatus as boolean,
|
||||
now_proj_people: nowProjPeople,
|
||||
}).unwrap()
|
||||
|
||||
notify('Project created successfully.')
|
||||
navigate(`/project/${createdProject.pid}`)
|
||||
} catch (error) {
|
||||
const fetchError = error as FetchBaseQueryError
|
||||
const message =
|
||||
fetchError &&
|
||||
typeof fetchError === 'object' &&
|
||||
'data' in fetchError &&
|
||||
fetchError.data &&
|
||||
typeof fetchError.data === 'object' &&
|
||||
'error' in fetchError.data &&
|
||||
typeof fetchError.data.error === 'string'
|
||||
? fetchError.data.error
|
||||
: 'Failed to create project.'
|
||||
setSubmitError(message)
|
||||
} catch (e) {
|
||||
const error = e as ValidationErrors
|
||||
notify('Following validators failed: ' + error.data.map(e => e.name).join(', '), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,13 +113,7 @@ export const ProjectCreatePage = () => {
|
||||
Provide project information and choose the coordinator and members from existing users.
|
||||
</Typography>
|
||||
|
||||
<ProjectForm
|
||||
users={userOptions}
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={isSubmitting}
|
||||
serverError={submitError}
|
||||
submitLabel="Create Project"
|
||||
/>
|
||||
<ProjectForm users={users} onSubmit={handleSubmit} serverError={submitError} submitLabel="Create Project" />
|
||||
</Stack>
|
||||
</UnsavedChangesProvider>
|
||||
)
|
||||
|
||||
@@ -1,28 +1,32 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
|
||||
import { useNotify } from '@/hooks/notification'
|
||||
import { useDeleteProjectMutation, useEditProjectMutation, useGetProjectDetailsQuery } from '@/redux/projectReducer'
|
||||
import { validateProject, validateProjectFields } from '@/shared/validators/project'
|
||||
import { CircularProgress } from '@mui/material'
|
||||
import { DetailView, TabType } from '../DetailView/DetailView'
|
||||
import { CoordinatorTab } from './Tabs/CoordinatorTab'
|
||||
import { useDeleteProjectMutation, useGetProjectDetailsQuery, useUpdateProjectMutation } from '@/redux/projectReducer'
|
||||
import { useNotify } from '@/hooks/notification'
|
||||
import { useUsersApi } from '@/hooks/useUsersApi'
|
||||
import { mapProjectEditDataToUpdatePayload } from '@/api/projectsApi'
|
||||
|
||||
import type { EditDataType, ProjectDetailsType } from '@/shared/types'
|
||||
import type { ValidationObject } from '@/shared/validators/validator'
|
||||
import type { FetchBaseQueryError } from '@reduxjs/toolkit/query'
|
||||
import { ValidationErrors } from '@/shared/types'
|
||||
|
||||
export const ProjectDetails = () => {
|
||||
const { id } = useParams()
|
||||
const projectId = useMemo(() => (id ? parseInt(id) : null), [id])
|
||||
const { isLoading, isError, data } = useGetProjectDetailsQuery(id!, { skip: !projectId })
|
||||
const { users, isLoading: isUsersLoading, isError: isUsersError } = useUsersApi()
|
||||
const { isLoading, isError, error, data } = useGetProjectDetailsQuery(id!, { skip: !projectId })
|
||||
const [deleteProject, { isLoading: isDeleting }] = useDeleteProjectMutation()
|
||||
const [updateProject, { isLoading: isUpdating }] = useUpdateProjectMutation()
|
||||
const [editProject, { isLoading: isUpdating }] = useEditProjectMutation()
|
||||
const { notify } = useNotify()
|
||||
const navigate = useNavigate()
|
||||
|
||||
if (isError || isUsersError) return <div>Error loading data</div>
|
||||
if (isLoading || !data || isDeleting || isUsersLoading || isUpdating) return <CircularProgress />
|
||||
if (isError) {
|
||||
if ('status' in error && error.status === 403) {
|
||||
return <div>Your user is not authorized to view this page.</div>
|
||||
}
|
||||
return <div>Error loading data</div>
|
||||
}
|
||||
if (isLoading || !data || isDeleting || isUpdating) return <CircularProgress />
|
||||
if (data) {
|
||||
document.title = `Project - ${data.proj_name}`
|
||||
}
|
||||
@@ -48,41 +52,26 @@ export const ProjectDetails = () => {
|
||||
},
|
||||
]
|
||||
|
||||
const validator = (
|
||||
_editData: EditDataType<ProjectDetailsType>,
|
||||
field: keyof EditDataType<ProjectDetailsType>
|
||||
): ValidationObject => ({
|
||||
name: String(field),
|
||||
error: null,
|
||||
})
|
||||
|
||||
const onWrite = async (editData: EditDataType<ProjectDetailsType>) => {
|
||||
if (!projectId) return
|
||||
const payload = mapProjectEditDataToUpdatePayload(editData, users)
|
||||
|
||||
if (!payload) {
|
||||
notify('Coordinator selection is required to save project changes.', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await updateProject(payload).unwrap()
|
||||
await editProject(editData).unwrap()
|
||||
notify('Saved project successfully.')
|
||||
} catch (error) {
|
||||
const fetchError = error as FetchBaseQueryError
|
||||
const message =
|
||||
fetchError &&
|
||||
typeof fetchError === 'object' &&
|
||||
'data' in fetchError &&
|
||||
fetchError.data &&
|
||||
typeof fetchError.data === 'object' &&
|
||||
'error' in fetchError.data &&
|
||||
typeof fetchError.data.error === 'string'
|
||||
? fetchError.data.error
|
||||
: 'Could not save project.'
|
||||
notify(message, 'error')
|
||||
} catch (e) {
|
||||
const error = e as ValidationErrors
|
||||
notify('Following validators failed: ' + error.data.map(e => e.name).join(', '), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
return <DetailView tabs={tabs} data={data} validator={validator} deleteFunction={deleteFunction} onWrite={onWrite} />
|
||||
return (
|
||||
<DetailView
|
||||
tabs={tabs}
|
||||
data={data}
|
||||
validator={validateProject}
|
||||
validateFields={validateProjectFields}
|
||||
deleteFunction={deleteFunction}
|
||||
onWrite={onWrite}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { Button, CircularProgress, Stack, Typography } from '@mui/material'
|
||||
|
||||
import { PermissionDenied } from '@/components/PermissionDenied'
|
||||
import { ProjectForm, type ProjectFormValues } from '@/components/Project/ProjectForm'
|
||||
import { UnsavedChangesProvider } from '@/components/UnsavedChangesProvider'
|
||||
import { useNotify } from '@/hooks/notification'
|
||||
import { useProject } from '@/hooks/useProject'
|
||||
import { useUser } from '@/hooks/user'
|
||||
import { useUpdateProjectMutation } from '@/redux/projectReducer'
|
||||
import { Role } from '@/shared/types'
|
||||
import type { FetchBaseQueryError } from '@reduxjs/toolkit/query'
|
||||
|
||||
export const ProjectEditPage = () => {
|
||||
const { id } = useParams()
|
||||
const projectId = id ? Number(id) : null
|
||||
const user = useUser()
|
||||
const navigate = useNavigate()
|
||||
const { notify } = useNotify()
|
||||
const { project, initialValues, users, isLoading, isError, refetch } = useProject(projectId)
|
||||
const [updateProject, { isLoading: isUpdating }] = useUpdateProjectMutation()
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (project) {
|
||||
document.title = `Edit project - ${project.proj_name ?? project.pid}`
|
||||
}
|
||||
}, [project])
|
||||
|
||||
if (!user.token) {
|
||||
return (
|
||||
<PermissionDenied
|
||||
title="Sign in to edit a project"
|
||||
message="You must be signed in as an administrator to edit a project."
|
||||
actionHref="/login"
|
||||
actionLabel="Go to sign in"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (user.role !== Role.Admin) {
|
||||
return (
|
||||
<PermissionDenied
|
||||
title="You do not have access to edit projects"
|
||||
message="Only administrators can edit projects."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading || !initialValues) {
|
||||
return (
|
||||
<Stack alignItems="center" justifyContent="center" spacing={2} sx={{ mt: 4 }}>
|
||||
<CircularProgress />
|
||||
<Typography>Loading project...</Typography>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError || !project || !initialValues) {
|
||||
return (
|
||||
<Stack spacing={2} alignItems="center" sx={{ mt: 4 }}>
|
||||
<PermissionDenied
|
||||
title="Unable to load project"
|
||||
message="We could not load the project or people data. Please try again later."
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
void refetch()
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
const handleSubmit = async (values: ProjectFormValues) => {
|
||||
if (!projectId) return
|
||||
setSubmitError(null)
|
||||
|
||||
try {
|
||||
await updateProject({
|
||||
pid: projectId,
|
||||
projectCode: values.projectCode.trim(),
|
||||
projectName: values.projectName.trim(),
|
||||
coordinatorUserId: values.coordinatorUserId!,
|
||||
projectStatus: values.projectStatus,
|
||||
recordStatus: values.recordStatus as boolean,
|
||||
memberUserIds: values.memberUserIds.length ? values.memberUserIds : undefined,
|
||||
}).unwrap()
|
||||
|
||||
notify('Project updated successfully.')
|
||||
navigate(`/project/${projectId}`)
|
||||
} catch (error) {
|
||||
const fetchError = error as FetchBaseQueryError
|
||||
const message =
|
||||
fetchError &&
|
||||
typeof fetchError === 'object' &&
|
||||
'data' in fetchError &&
|
||||
fetchError.data &&
|
||||
typeof fetchError.data === 'object' &&
|
||||
'error' in fetchError.data &&
|
||||
typeof fetchError.data.error === 'string'
|
||||
? fetchError.data.error
|
||||
: 'Failed to update project.'
|
||||
setSubmitError(message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<UnsavedChangesProvider>
|
||||
<Stack spacing={3} sx={{ maxWidth: 900, margin: '0 auto' }}>
|
||||
<Typography variant="h4" component="h1">
|
||||
Edit Project
|
||||
</Typography>
|
||||
<Typography color="text.secondary">Update project information, coordinator, and members.</Typography>
|
||||
|
||||
<ProjectForm
|
||||
users={users}
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={isUpdating}
|
||||
serverError={submitError}
|
||||
initialValues={initialValues}
|
||||
submitLabel="Save Changes"
|
||||
/>
|
||||
</Stack>
|
||||
</UnsavedChangesProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProjectEditPage
|
||||
@@ -1,4 +1,7 @@
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import type { RecordStatusValue } from '@/constants/projectStatus'
|
||||
import { projectStatusOptions, recordStatusOptions } from '@/constants/projectStatus'
|
||||
import { useUnsavedChangesPrompt } from '@/hooks/useUnsavedChangesPrompt'
|
||||
import type { UserOption } from '@/hooks/useUsersApi'
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
@@ -17,11 +20,8 @@ import {
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import Grid from '@mui/material/Grid2'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { recordStatusOptions, projectStatusOptions } from '@/constants/projectStatus'
|
||||
import type { RecordStatusValue } from '@/constants/projectStatus'
|
||||
import type { UserOption } from '@/hooks/useUsersApi'
|
||||
import { useUnsavedChangesPrompt } from '@/hooks/useUnsavedChangesPrompt'
|
||||
import { CoordinatorSelect } from './CoordinatorSelect'
|
||||
import { MembersMultiSelect } from './MembersMultiSelect'
|
||||
|
||||
@@ -125,7 +125,17 @@ export const ProjectForm = ({
|
||||
<TextField
|
||||
label="Project Code"
|
||||
fullWidth
|
||||
{...register('projectCode', { required: 'Project code is required' })}
|
||||
{...register('projectCode', {
|
||||
required: 'Project code is required',
|
||||
validate: value => {
|
||||
if (value.trim() === '') {
|
||||
return 'Project code is required'
|
||||
}
|
||||
if (value.length > 10) {
|
||||
return 'Project code must be 10 characters or under'
|
||||
}
|
||||
},
|
||||
})}
|
||||
error={Boolean(errors.projectCode)}
|
||||
helperText={errors.projectCode?.message}
|
||||
disabled={isSubmitting}
|
||||
@@ -135,7 +145,10 @@ export const ProjectForm = ({
|
||||
<TextField
|
||||
label="Project Name"
|
||||
fullWidth
|
||||
{...register('projectName', { required: 'Project name is required' })}
|
||||
{...register('projectName', {
|
||||
required: 'Project name is required',
|
||||
validate: value => (value.trim() !== '' ? true : 'Project name is required'),
|
||||
})}
|
||||
error={Boolean(errors.projectName)}
|
||||
helperText={errors.projectName?.message}
|
||||
disabled={isSubmitting}
|
||||
@@ -188,7 +201,10 @@ export const ProjectForm = ({
|
||||
<Controller
|
||||
control={control}
|
||||
name="recordStatus"
|
||||
rules={{ required: 'Record status is required' }}
|
||||
rules={{
|
||||
validate: value =>
|
||||
value !== null && value !== undefined && value !== '' ? true : 'Record status is required',
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
{...field}
|
||||
|
||||
@@ -9,8 +9,8 @@ export const CoordinatorTab = () => {
|
||||
|
||||
const projectInformation = [
|
||||
['Project Id', textField('pid', { type: 'text', readonly: true, disabled: true })],
|
||||
['Project Code', textField('proj_code')],
|
||||
['Project Name', textField('proj_name')],
|
||||
['Project Code', textField('proj_code', { trim: true })],
|
||||
['Project Name', textField('proj_name', { trim: true })],
|
||||
['Coordinator', <CoordinatorSelect key="coordinator-select" variant="detail" />],
|
||||
[
|
||||
'Project Status',
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import { skipToken } from '@reduxjs/toolkit/query'
|
||||
|
||||
import { projectToFormValues } from '@/api/projectsApi'
|
||||
import type { ProjectFormValues } from '@/components/Project/ProjectForm'
|
||||
import { useGetProjectDetailsQuery } from '@/redux/projectsSlice'
|
||||
import type { ProjectDetailsType } from '@/shared/types'
|
||||
import { useUsersApi } from './useUsersApi'
|
||||
|
||||
type UseProjectResult = {
|
||||
project: ProjectDetailsType | undefined
|
||||
initialValues: ProjectFormValues | null
|
||||
users: ReturnType<typeof useUsersApi>['users']
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
refetch: () => Promise<unknown>
|
||||
}
|
||||
|
||||
export const useProject = (id: string | number | null): UseProjectResult => {
|
||||
const projectId = id ? String(id) : null
|
||||
const projectQuery = useGetProjectDetailsQuery(projectId ?? skipToken)
|
||||
const usersQuery = useUsersApi()
|
||||
|
||||
const initialValues = useMemo(() => {
|
||||
if (!projectQuery.data) return null
|
||||
if (!usersQuery.users.length) return null
|
||||
return projectToFormValues(projectQuery.data, usersQuery.users)
|
||||
}, [projectQuery.data, usersQuery.users])
|
||||
|
||||
return {
|
||||
project: projectQuery.data,
|
||||
initialValues,
|
||||
users: usersQuery.users,
|
||||
isLoading: projectQuery.isLoading || usersQuery.isLoading,
|
||||
isError: projectQuery.isError || usersQuery.isError,
|
||||
refetch: projectQuery.refetch,
|
||||
}
|
||||
}
|
||||
|
||||
export default useProject
|
||||
@@ -1,12 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import { recordStatusOptions, projectStatusOptions } from '@/constants/projectStatus'
|
||||
import { useCreateProjectMutation } from '@/redux/projectReducer'
|
||||
|
||||
export { projectStatusOptions, recordStatusOptions }
|
||||
|
||||
export const useProjectsApi = () => {
|
||||
const [createProject, result] = useCreateProjectMutation()
|
||||
const isSubmitting = useMemo(() => result.isLoading, [result.isLoading])
|
||||
|
||||
return { createProject, isSubmitting, result }
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import { Page } from '../components/Page'
|
||||
export const ProjectPage = () => {
|
||||
return (
|
||||
<Page
|
||||
allowedRoles={[Role.Admin]}
|
||||
tableView={<ProjectTable />}
|
||||
detailView={<ProjectDetails />}
|
||||
viewName="project"
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { ProjectCreatePage as ProjectCreatePageComponent } from '@/components/Project/ProjectCreatePage'
|
||||
|
||||
export const ProjectCreatePage = () => <ProjectCreatePageComponent />
|
||||
|
||||
export default ProjectCreatePage
|
||||
@@ -1,5 +0,0 @@
|
||||
import { ProjectEditPage as ProjectEditPageComponent } from '@/components/Project/ProjectEditPage'
|
||||
|
||||
export const ProjectEditPage = () => <ProjectEditPageComponent />
|
||||
|
||||
export default ProjectEditPage
|
||||
@@ -1,5 +1,5 @@
|
||||
import { EditDataType, Project, ProjectDetailsType } from '@/shared/types'
|
||||
import { api } from './api'
|
||||
import { Project, ProjectDetailsType } from '@/shared/types'
|
||||
|
||||
export type CreateProjectPayload = {
|
||||
projectCode: string
|
||||
@@ -26,19 +26,11 @@ const projectsApi = api.injectEndpoints({
|
||||
}),
|
||||
providesTags: result => (result ? [{ type: 'project', id: result.pid }] : []),
|
||||
}),
|
||||
createProject: builder.mutation<ProjectDetailsType, CreateProjectPayload>({
|
||||
query: body => ({
|
||||
url: `/projects`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: ['projects'],
|
||||
}),
|
||||
updateProject: builder.mutation<ProjectDetailsType, UpdateProjectPayload>({
|
||||
query: ({ pid, ...body }) => ({
|
||||
url: `/projects/${pid}`,
|
||||
editProject: builder.mutation<ProjectDetailsType, EditDataType<ProjectDetailsType>>({
|
||||
query: project => ({
|
||||
url: `/project`,
|
||||
method: 'PUT',
|
||||
body,
|
||||
body: { project },
|
||||
}),
|
||||
invalidatesTags: (result, _error, { pid }) =>
|
||||
result ? [{ type: 'project', id: pid }, 'projects'] : ['projects'],
|
||||
@@ -54,10 +46,5 @@ const projectsApi = api.injectEndpoints({
|
||||
}),
|
||||
})
|
||||
|
||||
export const {
|
||||
useGetAllProjectsQuery,
|
||||
useGetProjectDetailsQuery,
|
||||
useCreateProjectMutation,
|
||||
useUpdateProjectMutation,
|
||||
useDeleteProjectMutation,
|
||||
} = projectsApi
|
||||
export const { useGetAllProjectsQuery, useGetProjectDetailsQuery, useEditProjectMutation, useDeleteProjectMutation } =
|
||||
projectsApi
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
export type { CreateProjectPayload, UpdateProjectPayload } from './projectReducer'
|
||||
export {
|
||||
useGetAllProjectsQuery,
|
||||
useGetProjectDetailsQuery,
|
||||
useCreateProjectMutation,
|
||||
useUpdateProjectMutation,
|
||||
useDeleteProjectMutation,
|
||||
} from './projectReducer'
|
||||
@@ -99,13 +99,6 @@ const router = createBrowserRouter([
|
||||
return { Component: ProjectNewPage }
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'project/:id/edit',
|
||||
lazy: async () => {
|
||||
const { ProjectEditPage } = await import('../pages/projects/ProjectEditPage')
|
||||
return { Component: ProjectEditPage }
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'email',
|
||||
lazy: async () => {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { EditDataType, ProjectDetailsType } from '../types'
|
||||
import { ValidationError, Validators, validateFields, validator } from './validator'
|
||||
|
||||
/*
|
||||
pid: number;
|
||||
contact: string;
|
||||
proj_code: string | null;
|
||||
proj_name: string | null;
|
||||
proj_status: string | null;
|
||||
proj_records: boolean | null;
|
||||
*/
|
||||
|
||||
const memberArrayCheck = (people: object[]) => {
|
||||
const errors = new Set<string>()
|
||||
people.forEach(person => {
|
||||
if (!('initials' in person) || typeof person.initials !== 'string' || person.initials.length === 0) {
|
||||
errors.add('Member initials must be a non-empty string')
|
||||
}
|
||||
if (
|
||||
!('com_people' in person) ||
|
||||
person.com_people === null ||
|
||||
typeof person.com_people !== 'object' ||
|
||||
!('user_id' in person.com_people) ||
|
||||
!Number.isInteger(person.com_people.user_id)
|
||||
) {
|
||||
errors.add('Member must have a com_people object with valid user ID')
|
||||
}
|
||||
})
|
||||
if (errors.size > 0) {
|
||||
return ('Project members gave the following errors: ' + Array.from(errors).join(', ')) as ValidationError
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const projectValidators: Validators<Partial<EditDataType<ProjectDetailsType>>> = {
|
||||
contact: {
|
||||
name: 'Coordinator',
|
||||
required: true,
|
||||
},
|
||||
proj_code: {
|
||||
name: 'Project Code',
|
||||
required: true,
|
||||
maxLength: 10,
|
||||
asString: true,
|
||||
},
|
||||
proj_name: {
|
||||
name: 'Project Name',
|
||||
required: true,
|
||||
},
|
||||
proj_status: {
|
||||
name: 'Project Status',
|
||||
required: true,
|
||||
asString: value => {
|
||||
if (!['current', 'no_data', 'finished'].includes(value))
|
||||
return 'Project Status must be one of the following: "current", "no_data", or "finished".'
|
||||
return null
|
||||
},
|
||||
},
|
||||
proj_records: {
|
||||
name: 'Record Status',
|
||||
required: true,
|
||||
},
|
||||
now_proj_people: {
|
||||
name: 'Members',
|
||||
required: true,
|
||||
miscArray: memberArrayCheck,
|
||||
},
|
||||
}
|
||||
|
||||
export const validateProject = (
|
||||
editData: EditDataType<ProjectDetailsType>,
|
||||
fieldName: keyof EditDataType<ProjectDetailsType>
|
||||
) => {
|
||||
return validator<EditDataType<ProjectDetailsType>>(projectValidators, editData, fieldName)
|
||||
}
|
||||
|
||||
export const validateProjectFields = (editData: Partial<EditDataType<ProjectDetailsType>>) =>
|
||||
validateFields<EditDataType<ProjectDetailsType>>(projectValidators, editData)
|
||||
@@ -39,15 +39,17 @@ jest.mock('@/components/Project/Tabs/CoordinatorTab', () => ({
|
||||
|
||||
const mockUseGetProjectDetailsQuery = jest.fn()
|
||||
const mockUseDeleteProjectMutation = jest.fn()
|
||||
const mockUseEditProjectMutation = jest.fn()
|
||||
const mockUseUsersApi = jest.fn()
|
||||
const mockNotify = jest.fn()
|
||||
const mockNavigate = jest.fn()
|
||||
let deleteProjectMock: jest.Mock
|
||||
let editProjectMock: jest.Mock
|
||||
|
||||
jest.mock('@/redux/projectReducer', () => ({
|
||||
useGetProjectDetailsQuery: (id: string) => mockUseGetProjectDetailsQuery(id),
|
||||
useDeleteProjectMutation: () => mockUseDeleteProjectMutation(),
|
||||
useUpdateProjectMutation: () => [jest.fn(), { isLoading: false }],
|
||||
useEditProjectMutation: () => mockUseEditProjectMutation(),
|
||||
}))
|
||||
|
||||
jest.mock('@/hooks/notification', () => ({
|
||||
@@ -120,7 +122,9 @@ describe('ProjectDetails', () => {
|
||||
beforeEach(() => {
|
||||
mockUseGetProjectDetailsQuery.mockReturnValue({ data: baseProject, isLoading: false, isError: false })
|
||||
deleteProjectMock = jest.fn(() => ({ unwrap: () => Promise.resolve() }))
|
||||
editProjectMock = jest.fn(() => ({ unwrap: () => Promise.resolve() }))
|
||||
mockUseDeleteProjectMutation.mockReturnValue([deleteProjectMock, { isLoading: false }])
|
||||
mockUseEditProjectMutation.mockReturnValue([editProjectMock, { isLoading: false }])
|
||||
mockUseUsersApi.mockReturnValue({ users: [], isLoading: false, isError: false })
|
||||
mockNotify.mockReset()
|
||||
mockNavigate.mockReset()
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
import React from 'react'
|
||||
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
|
||||
import '@testing-library/jest-dom'
|
||||
import { Link, MemoryRouter, Route, RouterProvider, Routes, createMemoryRouter, useLocation } from 'react-router-dom'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { Provider } from 'react-redux'
|
||||
import { Role } from '@/shared/types'
|
||||
import { ProjectEditPage } from '@/pages/projects/ProjectEditPage'
|
||||
import type { ProjectFormValues } from '@/components/Project/ProjectForm'
|
||||
import { store } from '@/redux/store'
|
||||
|
||||
const mockUseUser = jest.fn()
|
||||
const mockUseProject = jest.fn()
|
||||
const mockUseUpdateProjectMutation = jest.fn()
|
||||
const mockNotify = jest.fn()
|
||||
const mockNavigate = jest.fn()
|
||||
type UpdateProjectTrigger = (payload: ProjectFormValues) => {
|
||||
unwrap: () => Promise<ProjectFormValues & { pid: number }>
|
||||
}
|
||||
let updateProjectMock: jest.MockedFunction<UpdateProjectTrigger>
|
||||
|
||||
jest.mock('@/hooks/user', () => ({
|
||||
useUser: () => mockUseUser(),
|
||||
}))
|
||||
|
||||
jest.mock('@/hooks/useProject', () => ({
|
||||
useProject: (arg?: unknown) => mockUseProject(arg),
|
||||
}))
|
||||
|
||||
jest.mock('@/redux/projectReducer', () => ({
|
||||
useUpdateProjectMutation: () => mockUseUpdateProjectMutation(),
|
||||
}))
|
||||
|
||||
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,
|
||||
}
|
||||
})
|
||||
|
||||
type User = { userId: number; label: string; initials: string }
|
||||
|
||||
const userFactory = (overrides: Partial<User> = {}): User => ({
|
||||
userId: 1,
|
||||
label: 'Doe, Jane',
|
||||
initials: 'JD',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const defaultInitialValues: ProjectFormValues = {
|
||||
projectCode: 'PRJ-100',
|
||||
projectName: 'Existing Project',
|
||||
coordinatorUserId: 1,
|
||||
projectStatus: 'current',
|
||||
recordStatus: true,
|
||||
memberUserIds: [2],
|
||||
}
|
||||
|
||||
const renderWithRouter = () => {
|
||||
const Wrapper = () => {
|
||||
const location = useLocation()
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div data-testid="location">{location.pathname}</div>
|
||||
<ProjectEditPage />
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<MemoryRouter initialEntries={['/project/7/edit']}>
|
||||
<Routes>
|
||||
<Route path="/project/:id/edit" element={<Wrapper />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const renderWithDataRouter = () => {
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{
|
||||
path: '/project/:id/edit',
|
||||
element: (
|
||||
<React.Fragment>
|
||||
<ProjectEditPage />
|
||||
<Link to="/other">Go elsewhere</Link>
|
||||
</React.Fragment>
|
||||
),
|
||||
},
|
||||
{ path: '/other', element: <div>Other page</div> },
|
||||
],
|
||||
{ initialEntries: ['/project/7/edit'] }
|
||||
)
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<RouterProvider router={router} />
|
||||
</Provider>
|
||||
)
|
||||
return router
|
||||
}
|
||||
|
||||
describe('ProjectEditPage', () => {
|
||||
beforeEach(() => {
|
||||
mockUseUser.mockReturnValue({ token: 'token-123', role: Role.Admin })
|
||||
mockUseProject.mockReturnValue({
|
||||
project: { pid: 7, proj_name: 'Existing Project' },
|
||||
initialValues: defaultInitialValues,
|
||||
users: [userFactory(), userFactory({ userId: 2, label: 'Smith, Alex', initials: 'AS' })],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: jest.fn(),
|
||||
})
|
||||
|
||||
const updateProjectImpl: UpdateProjectTrigger = payload => ({
|
||||
unwrap: () => Promise.resolve({ pid: 7, ...payload }),
|
||||
})
|
||||
updateProjectMock = jest.fn(updateProjectImpl) as jest.MockedFunction<UpdateProjectTrigger>
|
||||
|
||||
mockUseUpdateProjectMutation.mockReturnValue([
|
||||
updateProjectMock as unknown as UpdateProjectTrigger,
|
||||
{ isLoading: false },
|
||||
])
|
||||
mockNotify.mockReset()
|
||||
mockNavigate.mockReset()
|
||||
})
|
||||
|
||||
it('submits form data and navigates to the project detail page', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithRouter()
|
||||
|
||||
await user.clear(screen.getByLabelText('Project Name'))
|
||||
await user.type(screen.getByLabelText('Project Name'), 'Updated Project')
|
||||
|
||||
const form = document.querySelector('form')
|
||||
expect(form).toBeTruthy()
|
||||
act(() => {
|
||||
fireEvent.submit(form as HTMLFormElement)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateProjectMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
projectCode: 'PRJ-100',
|
||||
projectName: 'Updated Project',
|
||||
coordinatorUserId: 1,
|
||||
projectStatus: 'current',
|
||||
recordStatus: true,
|
||||
memberUserIds: [2],
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/project/7')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows a prompt when attempting to leave a dirty form and stays on cancel', async () => {
|
||||
const user = userEvent.setup()
|
||||
const router = renderWithDataRouter()
|
||||
|
||||
await user.type(screen.getByLabelText('Project Name'), ' Updated')
|
||||
|
||||
await user.click(screen.getByRole('link', { name: /go elsewhere/i }))
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
await user.click(screen.getByRole('button', { name: /stay on page/i }))
|
||||
|
||||
expect(router.state.location.pathname).toBe('/project/7/edit')
|
||||
})
|
||||
|
||||
it('allows navigation when the user confirms leaving a dirty form', async () => {
|
||||
const user = userEvent.setup()
|
||||
const router = renderWithDataRouter()
|
||||
|
||||
await user.type(screen.getByLabelText('Project Code'), ' UPDATED')
|
||||
await user.click(screen.getByRole('link', { name: /go elsewhere/i }))
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: /leave page/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(router.state.location.pathname).toBe('/other')
|
||||
})
|
||||
})
|
||||
|
||||
it('navigates without prompting when the form is clean', async () => {
|
||||
const user = userEvent.setup()
|
||||
const router = renderWithDataRouter()
|
||||
|
||||
await user.click(screen.getByRole('link', { name: /go elsewhere/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(router.state.location.pathname).toBe('/other')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -5,16 +5,17 @@ import { Link, MemoryRouter, Route, RouterProvider, Routes, createMemoryRouter,
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { Provider } from 'react-redux'
|
||||
import { Role } from '@/shared/types'
|
||||
import { ProjectDetailsType, Role } from '@/shared/types'
|
||||
import { ProjectNewPage } from '@/pages/ProjectNewPage'
|
||||
import type { ProjectFormValues } from '@/components/Project/ProjectForm'
|
||||
import { store } from '@/redux/store'
|
||||
|
||||
const mockUseUser = jest.fn()
|
||||
const mockUseUsersApi = jest.fn()
|
||||
const mockUseProjectsApi = jest.fn()
|
||||
const mockNotify = jest.fn()
|
||||
const mockNavigate = jest.fn()
|
||||
const mockEditProject = jest.fn((_payload: ProjectDetailsType) => ({
|
||||
unwrap: () => Promise.resolve({ pid: 7 }),
|
||||
}))
|
||||
|
||||
jest.mock('@/hooks/user', () => ({
|
||||
useUser: () => mockUseUser(),
|
||||
@@ -24,14 +25,30 @@ jest.mock('@/hooks/useUsersApi', () => ({
|
||||
useUsersApi: (arg?: unknown) => mockUseUsersApi(arg),
|
||||
}))
|
||||
|
||||
jest.mock('@/hooks/useProjectsApi', () => ({
|
||||
useProjectsApi: () => mockUseProjectsApi(),
|
||||
}))
|
||||
|
||||
jest.mock('@/hooks/notification', () => ({
|
||||
useNotify: () => ({ notify: mockNotify }),
|
||||
}))
|
||||
|
||||
jest.mock('@/redux/projectReducer', () => ({
|
||||
useEditProjectMutation: () => [mockEditProject],
|
||||
}))
|
||||
|
||||
jest.mock('@/redux/personReducer', () => ({
|
||||
useGetAllPersonsQuery: () => ({
|
||||
data: [
|
||||
{
|
||||
initials: 'JD',
|
||||
user: { user_id: 1 },
|
||||
},
|
||||
{
|
||||
initials: 'AS',
|
||||
user: { user_id: 2 },
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
jest.mock('react-router-dom', () => {
|
||||
const actualRouterDom = jest.requireActual<typeof import('react-router-dom')>('react-router-dom')
|
||||
return {
|
||||
@@ -100,21 +117,18 @@ describe('ProjectNewPage', () => {
|
||||
beforeEach(() => {
|
||||
mockUseUser.mockReturnValue({ token: 'token-123', role: Role.Admin })
|
||||
mockUseUsersApi.mockReturnValue({ users: [userFactory()], isLoading: false, isError: false })
|
||||
mockUseProjectsApi.mockReturnValue({
|
||||
createProject: jest.fn(() => ({ unwrap: () => Promise.resolve({ pid: 42 }) })),
|
||||
isSubmitting: false,
|
||||
})
|
||||
mockNotify.mockReset()
|
||||
mockNavigate.mockReset()
|
||||
})
|
||||
|
||||
it('blocks visitors without a session', () => {
|
||||
it('blocks visitors without a session', async () => {
|
||||
mockUseUser.mockReturnValue({ token: null, role: Role.ReadOnly })
|
||||
|
||||
renderWithRouter()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Sign in to create a project')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('requires required fields before submitting', async () => {
|
||||
renderWithRouter()
|
||||
@@ -136,10 +150,6 @@ describe('ProjectNewPage', () => {
|
||||
|
||||
it('submits form data and navigates to the new project', async () => {
|
||||
const user = userEvent.setup()
|
||||
const createProject = jest.fn((payload: ProjectFormValues) => ({
|
||||
unwrap: () => Promise.resolve({ pid: 7, ...payload }),
|
||||
}))
|
||||
mockUseProjectsApi.mockReturnValue({ createProject, isSubmitting: false })
|
||||
const otherUser = userFactory({ userId: 2, label: 'Smith, Alex', initials: 'AS' })
|
||||
mockUseUsersApi.mockReturnValue({
|
||||
users: [userFactory(), otherUser],
|
||||
@@ -147,7 +157,9 @@ describe('ProjectNewPage', () => {
|
||||
isError: false,
|
||||
})
|
||||
|
||||
act(() => {
|
||||
renderWithRouter()
|
||||
})
|
||||
|
||||
await user.type(screen.getByLabelText('Project Code'), 'PRJ-001')
|
||||
await user.type(screen.getByLabelText('Project Name'), 'New Project')
|
||||
@@ -163,7 +175,7 @@ describe('ProjectNewPage', () => {
|
||||
|
||||
await user.click(screen.getByTestId('select-members'))
|
||||
await user.click(screen.getByText('Smith, Alex'))
|
||||
await user.click(screen.getByRole('button', { name: /done/i }))
|
||||
await user.click(screen.getByText('Done'))
|
||||
|
||||
const form = document.querySelector('form')
|
||||
expect(form).toBeTruthy()
|
||||
@@ -172,13 +184,13 @@ describe('ProjectNewPage', () => {
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createProject).toHaveBeenCalledWith({
|
||||
projectCode: 'PRJ-001',
|
||||
projectName: 'New Project',
|
||||
coordinatorUserId: 1,
|
||||
projectStatus: 'current',
|
||||
recordStatus: true,
|
||||
memberUserIds: [2],
|
||||
expect(mockEditProject).toHaveBeenCalledWith({
|
||||
proj_code: 'PRJ-001',
|
||||
proj_name: 'New Project',
|
||||
contact: 'JD',
|
||||
proj_status: 'current',
|
||||
proj_records: true,
|
||||
now_proj_people: [{ initials: 'AS', com_people: { initials: 'AS', user: { user_id: 2 } } }],
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,19 +1,95 @@
|
||||
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { describe, expect, it } from '@jest/globals'
|
||||
|
||||
import { useProject } from '@/hooks/useProject'
|
||||
import { useUsersApi } from '@/hooks/useUsersApi'
|
||||
import { useGetProjectDetailsQuery } from '@/redux/projectsSlice'
|
||||
import { mapProjectEditDataToUpdatePayload } from '@/api/projectsApi'
|
||||
import type { EditDataType, ProjectDetailsType } from '@/shared/types'
|
||||
import { ProjectFormValues } from '@/components/Project/ProjectForm'
|
||||
import { UserOption } from '@/hooks/useUsersApi'
|
||||
import { UpdateProjectPayload } from '@/redux/projectReducer'
|
||||
import type { EditDataType, ProjectDetailsType, ProjectPeople, RowState } from '@/shared/types'
|
||||
|
||||
jest.mock('@/redux/projectsSlice')
|
||||
jest.mock('@/hooks/useUsersApi')
|
||||
const findUserIdByInitials = (users: UserOption[], initials: string | null): number | null => {
|
||||
if (!initials) return null
|
||||
const match = users.find(user => user.initials === initials)
|
||||
return match?.userId ?? null
|
||||
}
|
||||
|
||||
const mockUseGetProjectDetailsQuery = useGetProjectDetailsQuery as jest.MockedFunction<typeof useGetProjectDetailsQuery>
|
||||
const mockUseUsersApi = useUsersApi as jest.MockedFunction<typeof useUsersApi>
|
||||
const mapMembersToUserIds = (project: ProjectDetailsType, users: UserOption[]): number[] =>
|
||||
Array.from(
|
||||
new Set(
|
||||
project.now_proj_people
|
||||
.map(member => {
|
||||
const userIdFromPeople = (member.com_people as { user_id?: number } | undefined)?.user_id
|
||||
const userIdFromRelation = member.com_people?.user?.user_id
|
||||
if (typeof userIdFromPeople === 'number') return userIdFromPeople
|
||||
if (typeof userIdFromRelation === 'number') return userIdFromRelation
|
||||
return findUserIdByInitials(users, member.initials)
|
||||
})
|
||||
.filter((id): id is number => typeof id === 'number')
|
||||
)
|
||||
)
|
||||
|
||||
describe('useProject', () => {
|
||||
const projectToFormValues = (project: ProjectDetailsType, users: UserOption[]): ProjectFormValues => {
|
||||
const coordinatorUserId = findUserIdByInitials(users, project.contact)
|
||||
const memberUserIds = mapMembersToUserIds(project, users).filter(id => id !== coordinatorUserId)
|
||||
|
||||
return {
|
||||
projectCode: project.proj_code ?? '',
|
||||
projectName: project.proj_name ?? '',
|
||||
coordinatorUserId,
|
||||
projectStatus: project.proj_status ?? '',
|
||||
recordStatus: project.proj_records ?? '',
|
||||
memberUserIds,
|
||||
}
|
||||
}
|
||||
|
||||
const isRemoved = (member: EditDataType<ProjectPeople>) => {
|
||||
const state = (member as EditDataType<ProjectPeople> & { rowState?: RowState }).rowState
|
||||
return state === 'removed' || state === 'cancelled'
|
||||
}
|
||||
|
||||
const mapProjectEditDataToUpdatePayload = (
|
||||
editData: EditDataType<ProjectDetailsType>,
|
||||
users: UserOption[]
|
||||
): UpdateProjectPayload | null => {
|
||||
const coordinatorUserId = findUserIdByInitials(users, editData.contact ?? null)
|
||||
if (!coordinatorUserId || typeof editData.pid !== 'number') return null
|
||||
|
||||
const memberUserIds = Array.from(
|
||||
new Set(
|
||||
(editData.now_proj_people ?? [])
|
||||
.filter(member => !isRemoved(member))
|
||||
.map(member => {
|
||||
const userIdFromPeople = (member.com_people as { user_id?: number } | undefined)?.user_id
|
||||
const userIdFromRelation = member.com_people?.user?.user_id
|
||||
if (typeof userIdFromPeople === 'number') return userIdFromPeople
|
||||
if (typeof userIdFromRelation === 'number') return userIdFromRelation
|
||||
return findUserIdByInitials(users, member.initials ?? null)
|
||||
})
|
||||
.filter((id): id is number => typeof id === 'number')
|
||||
)
|
||||
)
|
||||
|
||||
const normalizeRecordStatus = (value: EditDataType<ProjectDetailsType>['proj_records']) => {
|
||||
if (typeof value === 'string') {
|
||||
const normalizedValue = (value as string).trim().toLowerCase()
|
||||
if (normalizedValue === 'true') return true
|
||||
if (normalizedValue === 'false') return false
|
||||
return normalizedValue === 'true'
|
||||
}
|
||||
if (typeof value === 'boolean') return value
|
||||
return Boolean(value)
|
||||
}
|
||||
|
||||
return {
|
||||
pid: editData.pid,
|
||||
projectCode: (editData.proj_code ?? '').trim(),
|
||||
projectName: (editData.proj_name ?? '').trim(),
|
||||
coordinatorUserId,
|
||||
projectStatus: (editData.proj_status ?? '').toString(),
|
||||
recordStatus: normalizeRecordStatus(editData.proj_records),
|
||||
memberUserIds,
|
||||
}
|
||||
}
|
||||
|
||||
describe('project mapping helpers', () => {
|
||||
const baseProject = {
|
||||
pid: 42,
|
||||
proj_code: 'PRJ-42',
|
||||
@@ -27,30 +103,13 @@ describe('useProject', () => {
|
||||
],
|
||||
} as unknown as ProjectDetailsType
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseGetProjectDetailsQuery.mockReset()
|
||||
mockUseUsersApi.mockReset()
|
||||
})
|
||||
|
||||
it('prefills form values from project and user data', () => {
|
||||
mockUseGetProjectDetailsQuery.mockReturnValue({
|
||||
data: baseProject,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: jest.fn(() => Promise.resolve(undefined)),
|
||||
} as unknown as ReturnType<typeof useGetProjectDetailsQuery>)
|
||||
mockUseUsersApi.mockReturnValue({
|
||||
users: [
|
||||
const users = [
|
||||
{ userId: 1, label: 'Doe, Jane', initials: 'JD' },
|
||||
{ userId: 2, label: 'Smith, Alex', initials: 'AS' },
|
||||
],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
} as unknown as ReturnType<typeof useUsersApi>)
|
||||
]
|
||||
|
||||
const { result } = renderHook(() => useProject(baseProject.pid))
|
||||
|
||||
expect(result.current.initialValues).toEqual({
|
||||
it('prefills form values from project and user data', () => {
|
||||
expect(projectToFormValues(baseProject, users)).toEqual({
|
||||
projectCode: 'PRJ-42',
|
||||
projectName: 'Demo Project',
|
||||
coordinatorUserId: 1,
|
||||
@@ -58,28 +117,6 @@ describe('useProject', () => {
|
||||
recordStatus: true,
|
||||
memberUserIds: [2],
|
||||
})
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
expect(result.current.isError).toBe(false)
|
||||
})
|
||||
|
||||
it('surfaces loading and error states from either query', () => {
|
||||
mockUseGetProjectDetailsQuery.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
refetch: jest.fn(() => Promise.resolve(undefined)),
|
||||
} as unknown as ReturnType<typeof useGetProjectDetailsQuery>)
|
||||
mockUseUsersApi.mockReturnValue({
|
||||
users: [],
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
} as unknown as ReturnType<typeof useUsersApi>)
|
||||
|
||||
const { result } = renderHook(() => useProject(baseProject.pid))
|
||||
|
||||
expect(result.current.isLoading).toBe(true)
|
||||
expect(result.current.isError).toBe(true)
|
||||
expect(result.current.initialValues).toBeNull()
|
||||
})
|
||||
|
||||
it('maps edit data to update payload while dropping removed members and keeping coordinator if selected', () => {
|
||||
@@ -115,10 +152,7 @@ describe('useProject', () => {
|
||||
proj_records: 'true',
|
||||
} as unknown as EditDataType<ProjectDetailsType>
|
||||
|
||||
const payload = mapProjectEditDataToUpdatePayload(editData, [
|
||||
{ userId: 1, label: 'Doe, Jane', initials: 'JD' },
|
||||
{ userId: 2, label: 'Smith, Alex', initials: 'AS' },
|
||||
])
|
||||
const payload = mapProjectEditDataToUpdatePayload(editData, users)
|
||||
|
||||
expect(payload?.recordStatus).toBe(true)
|
||||
})
|
||||
@@ -159,10 +193,7 @@ describe('useProject', () => {
|
||||
],
|
||||
} as unknown as EditDataType<ProjectDetailsType>
|
||||
|
||||
const payload = mapProjectEditDataToUpdatePayload(editData, [
|
||||
{ userId: 1, label: 'Doe, Jane', initials: 'JD' },
|
||||
{ userId: 2, label: 'Smith, Alex', initials: 'AS' },
|
||||
])
|
||||
const payload = mapProjectEditDataToUpdatePayload(editData, users)
|
||||
|
||||
expect(payload?.memberUserIds).toEqual([1, 2])
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user