mirror of
https://github.com/nowcommunity/nowdatabase.git
synced 2026-10-12 19:13:52 +03:00
Merge pull request #997 from nowcommunity/codex/plan-project-creation-feature-implementation
Add project creation form
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from '@jest/globals'
|
||||
import { login, resetDatabase, resetDatabaseTimeout, send } from '../utils'
|
||||
import { pool } from '../../utils/db'
|
||||
|
||||
describe('DELETE /project/:id', () => {
|
||||
beforeAll(async () => {
|
||||
await resetDatabase()
|
||||
}, resetDatabaseTimeout)
|
||||
|
||||
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')
|
||||
|
||||
expect(deleted.status).toEqual(200)
|
||||
|
||||
const fetchAfterDelete = await send(`project/${createdProject.body.pid}`, 'GET')
|
||||
expect(fetchAfterDelete.status).toEqual(404)
|
||||
})
|
||||
|
||||
it('rejects deletion attempts from non-admin users', async () => {
|
||||
await login('testSu', 'test')
|
||||
const createdProject = await send<{ pid: number }>('projects', 'POST', {
|
||||
projectCode: 'DEL-002',
|
||||
projectName: 'Unauthorized Delete',
|
||||
coordinatorUserId: 163,
|
||||
})
|
||||
|
||||
await login('testPl', 'test')
|
||||
const deleteAttempt = await send(`project/${createdProject.body.pid}`, 'DELETE')
|
||||
|
||||
expect(deleteAttempt.status).toEqual(403)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from '@jest/globals'
|
||||
import { login, 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 () => {
|
||||
await login('testPl', 'test')
|
||||
|
||||
const result = await send('projects', 'POST', {
|
||||
projectCode: 'PRJ-004',
|
||||
projectName: 'Unauthorized Create',
|
||||
coordinatorUserId: 163,
|
||||
})
|
||||
|
||||
expect(result.status).toEqual(403)
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,7 @@ import sedimentaryStructureRouter from './routes/sedimentaryStructure'
|
||||
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'
|
||||
@@ -60,6 +61,7 @@ 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('/email', emailRouter)
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export const PROJECT_STATUSES = ['current', 'no_data', 'finished'] as const
|
||||
export type ProjectStatus = (typeof PROJECT_STATUSES)[number]
|
||||
|
||||
export const RECORD_STATUS_VALUES = [true, false] as const
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Request, Response } from 'express'
|
||||
import { createProject, CreateProjectInput } 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)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Router } from 'express'
|
||||
import { getAllProjects, getProjectDetails } from '../services/project'
|
||||
import { deleteProject, getAllProjects, getProjectDetails } from '../services/project'
|
||||
import { requireOneOf } from '../middlewares/authorizer'
|
||||
import { Role } from '../../../frontend/src/shared/types'
|
||||
|
||||
@@ -17,4 +17,13 @@ router.get('/:id', requireOneOf([Role.Admin]), async (req, res) => {
|
||||
return res.status(200).send(project)
|
||||
})
|
||||
|
||||
router.delete('/:id', requireOneOf([Role.Admin]), async (req, res) => {
|
||||
const id = parseInt(req.params.id)
|
||||
const deleted = await deleteProject(id)
|
||||
|
||||
if (!deleted) return res.status(404).send()
|
||||
|
||||
return res.status(200).send()
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Router } from 'express'
|
||||
import { createProjectHandler } from '../controllers/projectsController'
|
||||
import { requireOneOf } from '../middlewares/authorizer'
|
||||
import { Role } from '../../../frontend/src/shared/types'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.post('/', requireOneOf([Role.Admin]), createProjectHandler)
|
||||
|
||||
export default router
|
||||
@@ -20,3 +20,31 @@ export const getProjectDetails = async (id: number) => {
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
export const deleteProject = async (projectId: number) => {
|
||||
const project = await nowDb.now_proj.findUnique({
|
||||
where: { pid: projectId },
|
||||
})
|
||||
|
||||
if (!project) return false
|
||||
|
||||
await nowDb.$transaction(async prisma => {
|
||||
await prisma.now_proj_people.deleteMany({
|
||||
where: { pid: projectId },
|
||||
})
|
||||
|
||||
await prisma.now_psr.deleteMany({
|
||||
where: { pid: projectId },
|
||||
})
|
||||
|
||||
await prisma.now_plr.deleteMany({
|
||||
where: { pid: projectId },
|
||||
})
|
||||
|
||||
await prisma.now_proj.delete({
|
||||
where: { pid: projectId },
|
||||
})
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { PROJECT_STATUSES, type ProjectStatus, RECORD_STATUS_VALUES } from '../constants/status'
|
||||
import { nowDb } from '../utils/db'
|
||||
|
||||
export class ValidationError extends Error {
|
||||
declare status: number
|
||||
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.status = 400
|
||||
}
|
||||
}
|
||||
|
||||
export type CreateProjectInput = {
|
||||
projectCode: string
|
||||
projectName: string
|
||||
coordinatorUserId: number
|
||||
projectStatus?: ProjectStatus | null
|
||||
recordStatus?: boolean | null
|
||||
memberUserIds?: number[]
|
||||
}
|
||||
|
||||
const ensureNonEmptyString = (value: unknown, fieldName: string) => {
|
||||
if (typeof value !== 'string' || value.trim() === '') {
|
||||
throw new ValidationError(`${fieldName} is required`)
|
||||
}
|
||||
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
const ensureValidUserId = (value: unknown, fieldName: string) => {
|
||||
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
||||
throw new ValidationError(`${fieldName} must be a valid number`)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export const createProject = async ({
|
||||
projectCode,
|
||||
projectName,
|
||||
coordinatorUserId,
|
||||
projectStatus,
|
||||
recordStatus,
|
||||
memberUserIds,
|
||||
}: CreateProjectInput) => {
|
||||
const proj_code = ensureNonEmptyString(projectCode, 'Project code')
|
||||
const proj_name = ensureNonEmptyString(projectName, 'Project name')
|
||||
const coordinatorId = ensureValidUserId(coordinatorUserId, 'Coordinator user ID')
|
||||
const uniqueMemberIds = ensureValidMemberIds(memberUserIds)
|
||||
const validProjectStatus = ensureValidProjectStatus(projectStatus)
|
||||
const validRecordStatus = ensureValidRecordStatus(recordStatus)
|
||||
|
||||
const coordinator = await nowDb.com_people.findFirst({
|
||||
select: { initials: true },
|
||||
where: { user_id: coordinatorId },
|
||||
})
|
||||
|
||||
if (!coordinator) {
|
||||
throw new ValidationError('Coordinator user does not exist')
|
||||
}
|
||||
|
||||
const members = uniqueMemberIds.length
|
||||
? await nowDb.com_people.findMany({
|
||||
select: { initials: true, user_id: true },
|
||||
where: { user_id: { in: uniqueMemberIds } },
|
||||
})
|
||||
: []
|
||||
|
||||
if (members.length !== uniqueMemberIds.length) {
|
||||
throw new ValidationError('One or more project members do not exist')
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from '@jest/globals'
|
||||
import { PROJECT_STATUSES, RECORD_STATUS_VALUES } from '../../constants/status'
|
||||
|
||||
describe('status constants', () => {
|
||||
it('includes the expected project statuses', () => {
|
||||
expect(PROJECT_STATUSES).toEqual(['current', 'no_data', 'finished'])
|
||||
})
|
||||
|
||||
it('exposes record status booleans used for validation', () => {
|
||||
expect(RECORD_STATUS_VALUES).toEqual([true, false])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
# Write access guard
|
||||
|
||||
Global write access is controlled by two environment variables that apply to all edit and delete requests (as well as creates and updates) across the API.
|
||||
|
||||
## Environment variables
|
||||
- `RUNNING_ENV` (`VITE_RUNNING_ENV` in `.env`): Defaults to `prod` when unset. Accepted values: `dev`, `staging`, `prod`.
|
||||
- `VITE_ENABLE_WRITE`: When set to `true`, write operations are permitted even outside `dev`.
|
||||
|
||||
Both variables are parsed in the backend configuration (`backend/src/utils/config.ts`). The Express app installs the `blockWriteRequests` middleware whenever `RUNNING_ENV` is not `dev` **and** `VITE_ENABLE_WRITE` is not `true`. In that mode, any write method (`POST`, `PUT`, `PATCH`, `DELETE`) that is not explicitly whitelisted is short-circuited with HTTP 403. This affects project deletion and any other editing endpoints.
|
||||
|
||||
## Typical configurations
|
||||
- **Local development**: Use `RUNNING_ENV=dev` (default when running via Docker compose) to bypass the write guard entirely.
|
||||
- **Staging/production**: With `RUNNING_ENV` set to `staging` or `prod`, set `VITE_ENABLE_WRITE=true` to allow edits and deletions. If omitted, the write guard blocks modifications even for admin users.
|
||||
|
||||
## How to enable edits/deletes
|
||||
1. Confirm the container or process has `VITE_ENABLE_WRITE=true` in its environment (or set it in `.env`).
|
||||
2. Restart the backend so the updated environment variables are loaded.
|
||||
3. Retry the write action (e.g., deleting a project via `DELETE /project/:id`).
|
||||
|
||||
If writes remain blocked, inspect request logs for 403 responses; these indicate the write guard is still active.
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { Notification, NotificationContextProvider } from './components/Notification'
|
||||
import { EmailPage } from './components/EmailPage'
|
||||
import { Footer } from './Footer'
|
||||
import { ProjectNewPage } from './pages/ProjectNewPage'
|
||||
|
||||
const App = () => {
|
||||
return (
|
||||
@@ -41,6 +42,7 @@ const App = () => {
|
||||
<Route element={timeBoundPage} path="/time-bound/:id?" />
|
||||
<Route element={regionPage} path="/region/:id?" />
|
||||
<Route element={personPage} path="/person/:id?" />
|
||||
<Route element={<ProjectNewPage />} path="/project/new" />
|
||||
<Route element={projectPage} path="/project/:id?" />
|
||||
<Route element={<EmailPage />} path="/email/" />
|
||||
<Route element={<Login />} path="/login" />
|
||||
|
||||
@@ -1,19 +1,39 @@
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useGetProjectDetailsQuery } from '../../redux/projectReducer'
|
||||
import { useMemo } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { CircularProgress } from '@mui/material'
|
||||
import { DetailView, TabType } from '../DetailView/DetailView'
|
||||
import { CoordinatorTab } from './Tabs/CoordinatorTab'
|
||||
import { useDeleteProjectMutation, useGetProjectDetailsQuery } from '@/redux/projectReducer'
|
||||
import { useNotify } from '@/hooks/notification'
|
||||
|
||||
export const ProjectDetails = () => {
|
||||
const { id } = useParams()
|
||||
const { isLoading, isError, data } = useGetProjectDetailsQuery(id!)
|
||||
const projectId = useMemo(() => (id ? parseInt(id) : null), [id])
|
||||
const { isLoading, isError, data } = useGetProjectDetailsQuery(id!, { skip: !projectId })
|
||||
const [deleteProject, { isLoading: isDeleting }] = useDeleteProjectMutation()
|
||||
const { notify } = useNotify()
|
||||
const navigate = useNavigate()
|
||||
|
||||
if (isError) return <div>Error loading data</div>
|
||||
if (isLoading || !data) return <CircularProgress />
|
||||
if (isLoading || !data || isDeleting) return <CircularProgress />
|
||||
if (data) {
|
||||
document.title = `Project - ${data.proj_name}`
|
||||
}
|
||||
|
||||
const deleteFunction = async () => {
|
||||
if (!projectId) return
|
||||
|
||||
try {
|
||||
await deleteProject(projectId).unwrap()
|
||||
notify('Deleted project successfully.')
|
||||
navigate('/project')
|
||||
} catch (error) {
|
||||
const message =
|
||||
typeof error === 'object' && error && 'message' in error ? (error as { message: string }).message : undefined
|
||||
notify(message ?? 'Could not delete project.', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const tabs: TabType[] = [
|
||||
{
|
||||
title: 'Project',
|
||||
@@ -21,5 +41,7 @@ export const ProjectDetails = () => {
|
||||
},
|
||||
]
|
||||
|
||||
return <DetailView tabs={tabs} data={data} validator={() => ({ name: '', error: null })} />
|
||||
return (
|
||||
<DetailView tabs={tabs} data={data} validator={() => ({ name: '', error: null })} deleteFunction={deleteFunction} />
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
Autocomplete,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
Grid,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
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'
|
||||
|
||||
export type ProjectFormValues = {
|
||||
projectCode: string
|
||||
projectName: string
|
||||
coordinatorUserId: number | null
|
||||
projectStatus: string
|
||||
recordStatus: RecordStatusValue | ''
|
||||
memberUserIds: number[]
|
||||
}
|
||||
|
||||
export type ProjectFormProps = {
|
||||
users: UserOption[]
|
||||
onSubmit: (values: ProjectFormValues) => Promise<void>
|
||||
isSubmitting?: boolean
|
||||
serverError?: string | null
|
||||
}
|
||||
|
||||
export const ProjectForm = ({ users, onSubmit, isSubmitting = false, serverError }: ProjectFormProps) => {
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
watch,
|
||||
} = useForm<ProjectFormValues>({
|
||||
defaultValues: {
|
||||
projectCode: '',
|
||||
projectName: '',
|
||||
coordinatorUserId: null,
|
||||
projectStatus: '',
|
||||
recordStatus: '',
|
||||
memberUserIds: [],
|
||||
},
|
||||
})
|
||||
|
||||
const coordinatorId = watch('coordinatorUserId')
|
||||
const memberUserIds = watch('memberUserIds')
|
||||
const filteredMembers = useMemo(() => users.filter(user => user.userId !== coordinatorId), [coordinatorId, users])
|
||||
const recordStatusError = typeof errors.recordStatus?.message === 'string' ? errors.recordStatus.message : undefined
|
||||
|
||||
useEffect(() => {
|
||||
if (!coordinatorId) return
|
||||
if (!memberUserIds?.includes(coordinatorId)) return
|
||||
setValue(
|
||||
'memberUserIds',
|
||||
memberUserIds.filter(id => id !== coordinatorId)
|
||||
)
|
||||
}, [coordinatorId, memberUserIds, setValue])
|
||||
|
||||
const submitHandler = handleSubmit(async values => {
|
||||
await onSubmit(values)
|
||||
})
|
||||
|
||||
return (
|
||||
<Card
|
||||
component="form"
|
||||
onSubmit={event => {
|
||||
void submitHandler(event)
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Stack spacing={4}>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="h5">Project Information</Typography>
|
||||
<Divider />
|
||||
</Stack>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
label="Project Code"
|
||||
fullWidth
|
||||
{...register('projectCode', { required: 'Project code is required' })}
|
||||
error={Boolean(errors.projectCode)}
|
||||
helperText={errors.projectCode?.message}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
label="Project Name"
|
||||
fullWidth
|
||||
{...register('projectName', { required: 'Project name is required' })}
|
||||
error={Boolean(errors.projectName)}
|
||||
helperText={errors.projectName?.message}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="coordinatorUserId"
|
||||
rules={{ required: 'Coordinator is required' }}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Autocomplete
|
||||
value={users.find(user => user.userId === value) ?? null}
|
||||
onChange={(_event, option) => onChange(option?.userId ?? null)}
|
||||
options={users}
|
||||
getOptionLabel={option => option.label}
|
||||
renderInput={params => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Coordinator"
|
||||
required
|
||||
error={Boolean(errors.coordinatorUserId)}
|
||||
helperText={errors.coordinatorUserId?.message}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={3}>
|
||||
<FormControl fullWidth error={Boolean(errors.projectStatus)} disabled={isSubmitting}>
|
||||
<InputLabel id="project-status-label">Project Status</InputLabel>
|
||||
<Controller
|
||||
control={control}
|
||||
name="projectStatus"
|
||||
rules={{ required: 'Project status is required' }}
|
||||
render={({ field }) => (
|
||||
<Select {...field} labelId="project-status-label" label="Project Status" value={field.value ?? ''}>
|
||||
{projectStatusOptions.map(option => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
<FormHelperText>{errors.projectStatus?.message}</FormHelperText>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={3}>
|
||||
<FormControl fullWidth error={Boolean(errors.recordStatus)} disabled={isSubmitting}>
|
||||
<InputLabel id="record-status-label">Record Status</InputLabel>
|
||||
<Controller
|
||||
control={control}
|
||||
name="recordStatus"
|
||||
rules={{ required: 'Record status is required' }}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
{...field}
|
||||
labelId="record-status-label"
|
||||
label="Record Status"
|
||||
value={field.value === '' ? '' : String(field.value)}
|
||||
onChange={event => {
|
||||
const rawValue = event.target.value
|
||||
const mappedValue = rawValue === '' ? '' : rawValue === 'true'
|
||||
field.onChange(mappedValue)
|
||||
}}
|
||||
>
|
||||
{recordStatusOptions.map(option => (
|
||||
<MenuItem key={option.label} value={String(option.value)}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
<FormHelperText>{recordStatusError}</FormHelperText>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="h5">Project Members</Typography>
|
||||
<Divider />
|
||||
</Stack>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="memberUserIds"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Autocomplete
|
||||
multiple
|
||||
options={filteredMembers}
|
||||
value={filteredMembers.filter(option => value?.includes(option.userId))}
|
||||
onChange={(_event, newValue) => onChange(newValue.map(option => option.userId))}
|
||||
getOptionLabel={option => option.label}
|
||||
renderTags={(tagValue, getTagProps) =>
|
||||
tagValue.map((option, index) => {
|
||||
const { key, ...chipProps } = getTagProps({ index })
|
||||
return (
|
||||
<Box component="span" key={key ?? option.userId} {...chipProps}>
|
||||
{option.label}
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
}
|
||||
renderInput={params => (
|
||||
<TextField {...params} label="Members" placeholder="Select project members" disabled={isSubmitting} />
|
||||
)}
|
||||
disabled={isSubmitting || !users.length}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{serverError ? <Alert severity="error">{serverError}</Alert> : null}
|
||||
|
||||
<Box display="flex" justifyContent="flex-end" gap={2} alignItems="center">
|
||||
{isSubmitting && <CircularProgress size={24} />}
|
||||
<Button variant="contained" type="submit" disabled={isSubmitting}>
|
||||
Create Project
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProjectForm
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from '@jest/globals'
|
||||
import { projectStatusOptions, recordStatusOptions } from './projectStatus'
|
||||
|
||||
describe('project status constants', () => {
|
||||
it('maps the correct project status values and labels', () => {
|
||||
expect(projectStatusOptions).toEqual([
|
||||
{ value: 'current', label: 'Current' },
|
||||
{ value: 'no_data', label: 'No data' },
|
||||
{ value: 'finished', label: 'Finished' },
|
||||
])
|
||||
})
|
||||
|
||||
it('provides boolean-backed record status options', () => {
|
||||
expect(recordStatusOptions).toEqual([
|
||||
{ value: true, label: 'Public' },
|
||||
{ value: false, label: 'Private' },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
export const projectStatusOptions = [
|
||||
{ value: 'current', label: 'Current' },
|
||||
{ value: 'no_data', label: 'No data' },
|
||||
{ value: 'finished', label: 'Finished' },
|
||||
] as const
|
||||
|
||||
export type ProjectStatusValue = (typeof projectStatusOptions)[number]['value']
|
||||
|
||||
export const recordStatusOptions = [
|
||||
{ value: true, label: 'Public' },
|
||||
{ value: false, label: 'Private' },
|
||||
] as const
|
||||
|
||||
export type RecordStatusValue = (typeof recordStatusOptions)[number]['value']
|
||||
@@ -0,0 +1,12 @@
|
||||
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 }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useGetAllPersonsQuery } from '@/redux/personReducer'
|
||||
import type { PersonDetailsType } from '@/shared/types'
|
||||
|
||||
export type UserOption = {
|
||||
userId: number
|
||||
label: string
|
||||
initials: string
|
||||
}
|
||||
|
||||
type PersonWithUserId = PersonDetailsType & { user: NonNullable<PersonDetailsType['user']> & { user_id: number } }
|
||||
|
||||
const hasUserId = (person: PersonDetailsType): person is PersonWithUserId => typeof person.user?.user_id === 'number'
|
||||
|
||||
const formatUserLabel = ({
|
||||
surname,
|
||||
first_name,
|
||||
user,
|
||||
}: {
|
||||
surname: string | null
|
||||
first_name: string | null
|
||||
user?: { user_name: string | null }
|
||||
}) => {
|
||||
if (surname) {
|
||||
return `${surname}${first_name ? `, ${first_name}` : ''}`
|
||||
}
|
||||
|
||||
if (user?.user_name) return user.user_name
|
||||
|
||||
return 'Unknown user'
|
||||
}
|
||||
|
||||
export const useUsersApi = () => {
|
||||
const personsQuery = useGetAllPersonsQuery()
|
||||
|
||||
const users: UserOption[] = useMemo(() => {
|
||||
const personsWithUserIds: PersonWithUserId[] = (personsQuery.data ?? []).filter(hasUserId)
|
||||
|
||||
return personsWithUserIds
|
||||
.map(person => ({
|
||||
userId: person.user.user_id,
|
||||
label: formatUserLabel({ surname: person.surname, first_name: person.first_name, user: person.user }),
|
||||
initials: person.initials,
|
||||
}))
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
}, [personsQuery.data])
|
||||
|
||||
return { ...personsQuery, users }
|
||||
}
|
||||
|
||||
export default useUsersApi
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { CircularProgress, Stack, Typography } from '@mui/material'
|
||||
|
||||
import { PermissionDenied } from '@/components/PermissionDenied'
|
||||
import { ProjectForm, ProjectFormValues } from '@/components/Project/ProjectForm'
|
||||
import { useUser } from '@/hooks/user'
|
||||
import { useNotify } from '@/hooks/notification'
|
||||
import { useUsersApi } from '@/hooks/useUsersApi'
|
||||
import { Role } from '@/shared/types'
|
||||
import { useProjectsApi } from '@/hooks/useProjectsApi'
|
||||
import type { FetchBaseQueryError } from '@reduxjs/toolkit/query'
|
||||
|
||||
export const ProjectNewPage = () => {
|
||||
const user = useUser()
|
||||
const navigate = useNavigate()
|
||||
const { notify } = useNotify()
|
||||
const { users, isLoading: personsLoading, isError: personsError } = useUsersApi()
|
||||
const { createProject, isSubmitting } = useProjectsApi()
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'New project'
|
||||
}, [])
|
||||
|
||||
const userOptions = useMemo(() => users, [users])
|
||||
|
||||
if (!user.token) {
|
||||
return (
|
||||
<PermissionDenied
|
||||
title="Sign in to create a project"
|
||||
message="You must be signed in as an administrator to add a project."
|
||||
actionHref="/login"
|
||||
actionLabel="Go to sign in"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (user.role !== Role.Admin) {
|
||||
return (
|
||||
<PermissionDenied
|
||||
title="You do not have access to create projects"
|
||||
message="Only administrators can create new projects."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (personsLoading) {
|
||||
return (
|
||||
<Stack alignItems="center" justifyContent="center" spacing={2} sx={{ mt: 4 }}>
|
||||
<CircularProgress />
|
||||
<Typography>Loading people...</Typography>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
if (personsError) {
|
||||
return (
|
||||
<PermissionDenied
|
||||
title="Unable to load people"
|
||||
message="We could not load users to assign as coordinator or members. Please try again later."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const handleSubmit = async (values: ProjectFormValues) => {
|
||||
setSubmitError(null)
|
||||
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,
|
||||
}).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)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={3} sx={{ maxWidth: 900, margin: '0 auto' }}>
|
||||
<Typography variant="h4" component="h1">
|
||||
Create Project
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
Provide project information and choose the coordinator and members from existing users.
|
||||
</Typography>
|
||||
|
||||
<ProjectForm users={userOptions} onSubmit={handleSubmit} isSubmitting={isSubmitting} serverError={submitError} />
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProjectNewPage
|
||||
@@ -71,6 +71,8 @@ export const api = createApi({
|
||||
'person',
|
||||
'persons',
|
||||
'author',
|
||||
'project',
|
||||
'projects',
|
||||
'geoname',
|
||||
],
|
||||
baseQuery: baseQueryWithReauth,
|
||||
|
||||
@@ -1,19 +1,47 @@
|
||||
import { api } from './api'
|
||||
import { Project, ProjectDetailsType } from '@/shared/types'
|
||||
|
||||
export type CreateProjectPayload = {
|
||||
projectCode: string
|
||||
projectName: string
|
||||
coordinatorUserId: number
|
||||
projectStatus: string
|
||||
recordStatus: boolean
|
||||
memberUserIds?: number[]
|
||||
}
|
||||
|
||||
const projectsApi = api.injectEndpoints({
|
||||
endpoints: builder => ({
|
||||
getAllProjects: builder.query<Project[], void>({
|
||||
query: () => ({
|
||||
url: `/project/all`,
|
||||
}),
|
||||
providesTags: result => (result ? [{ type: 'projects' }] : []),
|
||||
}),
|
||||
getProjectDetails: builder.query<ProjectDetailsType, string>({
|
||||
query: id => ({
|
||||
url: `/project/${id}`,
|
||||
}),
|
||||
providesTags: result => (result ? [{ type: 'project', id: result.pid }] : []),
|
||||
}),
|
||||
createProject: builder.mutation<ProjectDetailsType, CreateProjectPayload>({
|
||||
query: body => ({
|
||||
url: `/projects`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: ['projects'],
|
||||
}),
|
||||
deleteProject: builder.mutation<void, number>({
|
||||
query: pid => ({
|
||||
url: `/project/${pid}`,
|
||||
method: 'DELETE',
|
||||
}),
|
||||
invalidatesTags: (result, _error, pid) =>
|
||||
typeof result !== 'undefined' ? [{ type: 'project', id: pid }, 'projects'] : [],
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
export const { useGetAllProjectsQuery, useGetProjectDetailsQuery } = projectsApi
|
||||
export const { useGetAllProjectsQuery, useGetProjectDetailsQuery, useCreateProjectMutation, useDeleteProjectMutation } =
|
||||
projectsApi
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
|
||||
import { MemoryRouter, Route, Routes, useParams } from 'react-router-dom'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
|
||||
import { ProjectDetails } from '@/components/Project/ProjectDetails'
|
||||
import { PageContext, PageContextType } from '@/components/Page'
|
||||
import { ProjectDetailsType } from '@/shared/types'
|
||||
|
||||
jest.mock('lodash-es', () => ({
|
||||
cloneDeep: (value: unknown) => value,
|
||||
}))
|
||||
|
||||
jest.mock('@/util/config', () => ({
|
||||
ENV: 'dev',
|
||||
BACKEND_URL: 'http://localhost',
|
||||
ENABLE_WRITE: true,
|
||||
}))
|
||||
|
||||
// Mock DetailView to avoid loading the full component tree with Redux dependencies
|
||||
jest.mock('@/components/DetailView/DetailView', () => ({
|
||||
DetailView: (props: { deleteFunction?: () => Promise<void> }) => {
|
||||
const handleDelete = () => {
|
||||
if (props.deleteFunction) void props.deleteFunction()
|
||||
}
|
||||
return (
|
||||
<div data-testid="detail-view">
|
||||
<button onClick={handleDelete}>Delete</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
const mockUseGetProjectDetailsQuery = jest.fn()
|
||||
const mockUseDeleteProjectMutation = jest.fn()
|
||||
const mockNotify = jest.fn()
|
||||
const mockNavigate = jest.fn()
|
||||
let deleteProjectMock: jest.Mock
|
||||
|
||||
jest.mock('@/redux/projectReducer', () => ({
|
||||
useGetProjectDetailsQuery: (id: string) => mockUseGetProjectDetailsQuery(id),
|
||||
useDeleteProjectMutation: () => mockUseDeleteProjectMutation(),
|
||||
}))
|
||||
|
||||
jest.mock('@/hooks/notification', () => ({
|
||||
useNotify: () => ({ notify: mockNotify }),
|
||||
}))
|
||||
|
||||
jest.mock('react-router-dom', () => {
|
||||
const actualRouterDom = jest.requireActual<typeof import('react-router-dom')>('react-router-dom')
|
||||
return {
|
||||
...actualRouterDom,
|
||||
useNavigate: () => mockNavigate,
|
||||
}
|
||||
})
|
||||
|
||||
const baseProject: ProjectDetailsType = {
|
||||
pid: 5,
|
||||
contact: 'TEST-SU',
|
||||
proj_code: 'PRJ-005',
|
||||
proj_name: 'Sample Project',
|
||||
proj_status: 'current',
|
||||
proj_records: false,
|
||||
now_proj_people: [],
|
||||
}
|
||||
|
||||
const pageContextValue: PageContextType<unknown> = {
|
||||
idList: ['5'],
|
||||
setIdList: jest.fn(),
|
||||
idFieldName: 'pid',
|
||||
viewName: 'project',
|
||||
previousTableUrls: [],
|
||||
setPreviousTableUrls: jest.fn(),
|
||||
createTitle: () => baseProject.proj_name ?? '',
|
||||
createSubtitle: () => '',
|
||||
editRights: { edit: true, delete: true },
|
||||
sqlLimit: 20,
|
||||
sqlOffset: 0,
|
||||
sqlColumnFilters: [],
|
||||
sqlOrderBy: [],
|
||||
setSqlLimit: jest.fn(),
|
||||
setSqlOffset: jest.fn(),
|
||||
setSqlColumnFilters: jest.fn(),
|
||||
setSqlOrderBy: jest.fn(),
|
||||
}
|
||||
|
||||
const Wrapper = () => {
|
||||
// Ensures useParams returns an id during tests when rendered outside of Page
|
||||
useParams()
|
||||
return <ProjectDetails />
|
||||
}
|
||||
|
||||
const renderWithProviders = () => {
|
||||
render(
|
||||
<PageContext.Provider value={pageContextValue}>
|
||||
<MemoryRouter initialEntries={['/project/5']}>
|
||||
<Routes>
|
||||
<Route path="/project/:id" element={<Wrapper />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</PageContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('ProjectDetails', () => {
|
||||
beforeEach(() => {
|
||||
mockUseGetProjectDetailsQuery.mockReturnValue({ data: baseProject, isLoading: false, isError: false })
|
||||
deleteProjectMock = jest.fn(() => ({ unwrap: () => Promise.resolve() }))
|
||||
mockUseDeleteProjectMutation.mockReturnValue([deleteProjectMock, { isLoading: false }])
|
||||
mockNotify.mockReset()
|
||||
mockNavigate.mockReset()
|
||||
window.confirm = jest.fn(() => true) as unknown as typeof window.confirm
|
||||
})
|
||||
|
||||
it('deletes the project and navigates back to the list', async () => {
|
||||
renderWithProviders()
|
||||
|
||||
const deleteButton = screen.getByRole('button', { name: /delete/i })
|
||||
await userEvent.click(deleteButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteProjectMock).toHaveBeenCalledWith(baseProject.pid)
|
||||
})
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith('Deleted project successfully.')
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/project')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
|
||||
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { Role } from '@/shared/types'
|
||||
import { ProjectNewPage } from '@/pages/ProjectNewPage'
|
||||
import type { ProjectFormValues } from '@/components/Project/ProjectForm'
|
||||
|
||||
const mockUseUser = jest.fn()
|
||||
const mockUseUsersApi = jest.fn()
|
||||
const mockUseProjectsApi = jest.fn()
|
||||
const mockNotify = jest.fn()
|
||||
const mockNavigate = jest.fn()
|
||||
|
||||
jest.mock('@/hooks/user', () => ({
|
||||
useUser: () => mockUseUser(),
|
||||
}))
|
||||
|
||||
jest.mock('@/hooks/useUsersApi', () => ({
|
||||
useUsersApi: (arg?: unknown) => mockUseUsersApi(arg),
|
||||
}))
|
||||
|
||||
jest.mock('@/hooks/useProjectsApi', () => ({
|
||||
useProjectsApi: () => mockUseProjectsApi(),
|
||||
}))
|
||||
|
||||
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 renderWithRouter = () => {
|
||||
const Wrapper = () => {
|
||||
const location = useLocation()
|
||||
return (
|
||||
<>
|
||||
<div data-testid="location">{location.pathname}</div>
|
||||
<ProjectNewPage />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={[{ pathname: '/project/new' }]}>
|
||||
<Routes>
|
||||
<Route path="/project/new" element={<Wrapper />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
)
|
||||
}
|
||||
|
||||
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', () => {
|
||||
mockUseUser.mockReturnValue({ token: null, role: Role.ReadOnly })
|
||||
|
||||
renderWithRouter()
|
||||
|
||||
expect(screen.getByText('Sign in to create a project')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('requires required fields before submitting', async () => {
|
||||
renderWithRouter()
|
||||
|
||||
const form = document.querySelector('form')
|
||||
expect(form).toBeTruthy()
|
||||
act(() => {
|
||||
fireEvent.submit(form as HTMLFormElement)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Project code is required')).toBeTruthy()
|
||||
})
|
||||
expect(screen.getByText('Project name is required')).toBeTruthy()
|
||||
expect(screen.getByText('Coordinator is required')).toBeTruthy()
|
||||
expect(screen.getByText('Project status is required')).toBeTruthy()
|
||||
expect(screen.getByText('Record status is required')).toBeTruthy()
|
||||
})
|
||||
|
||||
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],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
})
|
||||
|
||||
renderWithRouter()
|
||||
|
||||
await user.type(screen.getByLabelText('Project Code'), 'PRJ-001')
|
||||
await user.type(screen.getByLabelText('Project Name'), 'New Project')
|
||||
|
||||
// Use combobox role for MUI Autocomplete components
|
||||
const comboboxes = screen.getAllByRole('combobox')
|
||||
// comboboxes order: Coordinator, Project Status, Record Status, Members
|
||||
await user.click(comboboxes[0]) // Coordinator
|
||||
await user.click(screen.getByText('Doe, Jane'))
|
||||
|
||||
await user.click(comboboxes[1]) // Project Status
|
||||
await user.click(screen.getByRole('option', { name: 'Current' }))
|
||||
|
||||
await user.click(comboboxes[2]) // Record Status
|
||||
await user.click(screen.getByRole('option', { name: 'Public' }))
|
||||
|
||||
await user.click(comboboxes[3]) // Members
|
||||
await user.click(screen.getByText('Smith, Alex'))
|
||||
|
||||
const form = document.querySelector('form')
|
||||
expect(form).toBeTruthy()
|
||||
act(() => {
|
||||
fireEvent.submit(form as HTMLFormElement)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createProject).toHaveBeenCalledWith({
|
||||
projectCode: 'PRJ-001',
|
||||
projectName: 'New Project',
|
||||
coordinatorUserId: 1,
|
||||
projectStatus: 'current',
|
||||
recordStatus: true,
|
||||
memberUserIds: [2],
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/project/7')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user