Add project creation form

This commit is contained in:
Kari Lintulaakso
2025-11-28 13:57:12 +02:00
parent d7216ff218
commit a3a5587d9a
11 changed files with 775 additions and 1 deletions
@@ -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)
})
})
+2
View File
@@ -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,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)
}
+10
View File
@@ -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
+107
View File
@@ -0,0 +1,107 @@
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?: string | 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))
}
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 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: projectStatus ?? null,
proj_records: recordStatus ?? null,
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
}
+2
View File
@@ -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" />
@@ -0,0 +1,233 @@
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, type RecordStatusOption } from '@/hooks/useProjectsApi'
export type UserOption = {
userId: number
label: string
initials: string
}
export type ProjectFormValues = {
projectCode: string
projectName: string
coordinatorUserId: number | null
projectStatus: string
recordStatus: RecordStatusOption['value'] | ''
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 ?? ''}>
{recordStatusOptions.map(option => (
<MenuItem key={option.label} value={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
+23
View File
@@ -0,0 +1,23 @@
import { useMemo } from 'react'
import { useCreateProjectMutation } from '@/redux/projectReducer'
export type ProjectStatusOption = { value: string; label: string }
export type RecordStatusOption = { value: 'public' | 'private'; label: string }
export const projectStatusOptions: ProjectStatusOption[] = [
{ value: 'current', label: 'Current' },
{ value: 'no_data', label: 'No data' },
{ value: 'finished', label: 'Finished' },
]
export const recordStatusOptions: RecordStatusOption[] = [
{ value: 'public', label: 'Public' },
{ value: 'private', label: 'Private' },
]
export const useProjectsApi = () => {
const [createProject, result] = useCreateProjectMutation()
const isSubmitting = useMemo(() => result.isLoading, [result.isLoading])
return { createProject, isSubmitting, result }
}
+142
View File
@@ -0,0 +1,142 @@
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, UserOption } from '@/components/Project/ProjectForm'
import { useUser } from '@/hooks/user'
import { useNotify } from '@/hooks/notification'
import { useGetAllPersonsQuery } from '@/redux/personReducer'
import { Role, type PersonDetailsType } from '@/shared/types'
import { useProjectsApi } from '@/hooks/useProjectsApi'
import type { FetchBaseQueryError } from '@reduxjs/toolkit/query'
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 ProjectNewPage = () => {
const user = useUser()
const navigate = useNavigate()
const { notify } = useNotify()
const { data: persons, isLoading: personsLoading, isError: personsError } = useGetAllPersonsQuery()
const { createProject, isSubmitting } = useProjectsApi()
const [submitError, setSubmitError] = useState<string | null>(null)
useEffect(() => {
document.title = 'New project'
}, [])
const userOptions: UserOption[] = useMemo(() => {
const personsWithUserIds: PersonWithUserId[] = (persons ?? []).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))
}, [persons])
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 === 'private',
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
+17 -1
View File
@@ -1,6 +1,15 @@
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>({
@@ -13,7 +22,14 @@ const projectsApi = api.injectEndpoints({
url: `/project/${id}`,
}),
}),
createProject: builder.mutation<ProjectDetailsType, CreateProjectPayload>({
query: body => ({
url: `/projects`,
method: 'POST',
body,
}),
}),
}),
})
export const { useGetAllProjectsQuery, useGetProjectDetailsQuery } = projectsApi
export const { useGetAllProjectsQuery, useGetProjectDetailsQuery, useCreateProjectMutation } = projectsApi
@@ -0,0 +1,159 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'
import { 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 mockUseGetAllPersonsQuery = jest.fn()
const mockUseProjectsApi = jest.fn()
const mockNotify = jest.fn()
const mockNavigate = jest.fn()
jest.mock('@/hooks/user', () => ({
useUser: () => mockUseUser(),
}))
jest.mock('@/redux/personReducer', () => ({
useGetAllPersonsQuery: (arg?: unknown) => mockUseGetAllPersonsQuery(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 Person = {
initials: string
surname: string | null
first_name: string | null
user: { user_id: number; user_name: string | null; now_user_group?: string | null; last_login?: string | null }
}
const personFactory = (overrides: Partial<Person> = {}): Person => ({
initials: 'JD',
surname: 'Doe',
first_name: 'Jane',
user: { user_id: 1, user_name: 'jdoe', now_user_group: null, last_login: null },
...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 })
mockUseGetAllPersonsQuery.mockReturnValue({ data: [personFactory()], 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 () => {
const user = userEvent.setup()
renderWithRouter()
await user.click(screen.getByRole('button', { name: /create project/i }))
expect(await screen.findByText('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 otherPerson = personFactory({
initials: 'AS',
surname: 'Smith',
first_name: 'Alex',
user: { user_id: 2, user_name: 'asmith' },
})
mockUseGetAllPersonsQuery.mockReturnValue({
data: [personFactory(), otherPerson],
isLoading: false,
isError: false,
})
renderWithRouter()
await user.type(screen.getByLabelText('Project Code'), 'PRJ-001')
await user.type(screen.getByLabelText('Project Name'), 'New Project')
await user.click(screen.getByLabelText('Coordinator'))
await user.click(screen.getByText('Doe, Jane'))
await user.click(screen.getByLabelText('Project Status'))
await user.click(screen.getByRole('option', { name: 'Current' }))
await user.click(screen.getByLabelText('Record Status'))
await user.click(screen.getByRole('option', { name: 'Public' }))
await user.click(screen.getByLabelText('Members'))
await user.click(screen.getByText('Smith, Alex'))
await user.click(screen.getByRole('button', { name: /create project/i }))
await waitFor(() => {
expect(createProject).toHaveBeenCalledWith({
projectCode: 'PRJ-001',
projectName: 'New Project',
coordinatorUserId: 1,
projectStatus: 'current',
recordStatus: false,
memberUserIds: [2],
})
})
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/project/7')
})
})
})