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:
juhanikat
2026-08-19 11:21:36 +03:00
committed by GitHub
parent c3eeb10262
commit 5c065508b4
39 changed files with 854 additions and 1212 deletions
-88
View File
@@ -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,
}
}
+1 -1
View File
@@ -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',
-40
View File
@@ -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
-12
View File
@@ -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 }
}
-1
View File
@@ -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
+7 -20
View File
@@ -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
-8
View File
@@ -1,8 +0,0 @@
export type { CreateProjectPayload, UpdateProjectPayload } from './projectReducer'
export {
useGetAllProjectsQuery,
useGetProjectDetailsQuery,
useCreateProjectMutation,
useUpdateProjectMutation,
useDeleteProjectMutation,
} from './projectReducer'
-7
View File
@@ -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 () => {
+78
View File
@@ -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,20 +117,17 @@ 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()
expect(screen.getByText('Sign in to create a project')).toBeTruthy()
await waitFor(() => {
expect(screen.getByText('Sign in to create a project')).toBeTruthy()
})
})
it('requires required fields before submitting', async () => {
@@ -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,
})
renderWithRouter()
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 } } }],
})
})
+95 -64
View File
@@ -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()
})
const users = [
{ userId: 1, label: 'Doe, Jane', initials: 'JD' },
{ userId: 2, label: 'Smith, Alex', initials: 'AS' },
]
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: [
{ 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({
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])
})