From 57eec7c149d791b4086d5d87da0baff125a99613 Mon Sep 17 00:00:00 2001 From: juhanikat Date: Tue, 4 Aug 2026 16:10:05 +0300 Subject: [PATCH 1/5] update project update prosess in frontend, and add project validators --- .../src/components/Project/ProjectDetails.tsx | 59 +++++++++++++++++-- .../Project/Tabs/CoordinatorTab.tsx | 4 +- frontend/src/shared/validators/project.ts | 54 +++++++++++++++++ 3 files changed, 109 insertions(+), 8 deletions(-) create mode 100644 frontend/src/shared/validators/project.ts diff --git a/frontend/src/components/Project/ProjectDetails.tsx b/frontend/src/components/Project/ProjectDetails.tsx index ba4112c3e..121103af6 100755 --- a/frontend/src/components/Project/ProjectDetails.tsx +++ b/frontend/src/components/Project/ProjectDetails.tsx @@ -1,12 +1,20 @@ import { useMemo } from 'react' import { useNavigate, useParams } from 'react-router-dom' + +import { useNotify } from '@/hooks/notification' +import { useUsersApi } from '@/hooks/useUsersApi' +import { + UpdateProjectPayload, + useDeleteProjectMutation, + useGetProjectDetailsQuery, + useUpdateProjectMutation, +} from '@/redux/projectReducer' +import { ProjectPeople, RowState } from '@/shared/types' 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' @@ -56,15 +64,54 @@ export const ProjectDetails = () => { error: null, }) + const createProjectUpdatePayload = (editData: EditDataType): UpdateProjectPayload | null => { + const userOption = users.find(user => user.initials === editData.contact) + if (!userOption) { + notify('Could not find selected coordinator.', 'error') + return null + } + + const isRemoved = (member: EditDataType) => { + const state = (member as EditDataType & { rowState?: RowState }).rowState + return state === 'removed' || state === 'cancelled' + } + + 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 users.find(user => user.initials === member.initials) ?? null + }) + .filter((id): id is number => typeof id === 'number') + ) + ) + return { + pid: editData.pid!, + projectCode: editData.proj_code!, + projectName: editData.proj_name!, + coordinatorUserId: userOption.userId, + projectStatus: editData.proj_status!, + recordStatus: editData.proj_records!, + memberUserIds, + } + } + const onWrite = async (editData: EditDataType) => { if (!projectId) return - const payload = mapProjectEditDataToUpdatePayload(editData, users) + const payload = createProjectUpdatePayload(editData) if (!payload) { - notify('Coordinator selection is required to save project changes.', 'error') return } + console.log(editData) + console.log(payload) + try { await updateProject(payload).unwrap() notify('Saved project successfully.') diff --git a/frontend/src/components/Project/Tabs/CoordinatorTab.tsx b/frontend/src/components/Project/Tabs/CoordinatorTab.tsx index a670764bf..e209095d6 100755 --- a/frontend/src/components/Project/Tabs/CoordinatorTab.tsx +++ b/frontend/src/components/Project/Tabs/CoordinatorTab.tsx @@ -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', ], [ 'Project Status', diff --git a/frontend/src/shared/validators/project.ts b/frontend/src/shared/validators/project.ts new file mode 100644 index 000000000..2622e0dba --- /dev/null +++ b/frontend/src/shared/validators/project.ts @@ -0,0 +1,54 @@ +import { EditDataType, Project } from '../types' +import { 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 projectValidators: Validators>> = { + pid: { + name: 'Project Id', + required: true, + }, + contact: { + name: 'Coordinator', + required: true, + }, + proj_code: { + name: 'Project Code', + required: 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, + asString: value => { + if (!['public', 'private'].includes(value)) return 'Record Status must be either true or false.' + return null + }, + }, +} + +export const validateProject = (editData: EditDataType, fieldName: keyof EditDataType) => { + return validator>(projectValidators, editData, fieldName) +} + +export const validateProjectFields = (editData: Partial>) => + validateFields>(projectValidators, editData) From 57824f4b9e5a3d3f9add394fd8afb1aeea90ea2b Mon Sep 17 00:00:00 2001 From: juhanikat Date: Wed, 5 Aug 2026 13:20:56 +0300 Subject: [PATCH 2/5] change project update process in backend --- backend/src/controllers/projectsController.ts | 14 ---- backend/src/routes/projects.ts | 26 ++++-- backend/src/services/project.ts | 15 +++- backend/src/services/write/project.ts | 81 ++++++++++++++++++ backend/src/validators/projectsValidator.ts | 10 +-- .../src/components/Project/ProjectDetails.tsx | 83 ++++--------------- frontend/src/redux/projectReducer.ts | 10 +-- frontend/src/shared/validators/project.ts | 19 ++--- 8 files changed, 149 insertions(+), 109 deletions(-) delete mode 100644 backend/src/controllers/projectsController.ts create mode 100644 backend/src/services/write/project.ts diff --git a/backend/src/controllers/projectsController.ts b/backend/src/controllers/projectsController.ts deleted file mode 100644 index e6070677a..000000000 --- a/backend/src/controllers/projectsController.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Request, Response } from 'express' -import { createProject, CreateProjectInput, updateProject, UpdateProjectInput } from '../services/projectsService' - -export const createProjectHandler = async (req: Request, res: Response) => { - const createdProject = await createProject(req.body) - - return res.status(201).json(createdProject) -} - -export const updateProjectHandler = async (req: Request<{ id: string }, object, UpdateProjectInput>, res: Response) => { - const updatedProject = await updateProject(Number(req.params.id), req.body) - - return res.status(200).json(updatedProject) -} diff --git a/backend/src/routes/projects.ts b/backend/src/routes/projects.ts index 2d30e4071..d575315ed 100644 --- a/backend/src/routes/projects.ts +++ b/backend/src/routes/projects.ts @@ -1,11 +1,27 @@ -import { Router } from 'express' -import { createProjectHandler, updateProjectHandler } from '../controllers/projectsController' +import { Request, Router } from 'express' +import { validateEntireProject } from 'src/services/project' +import { writeProject } from 'src/services/write/project' +import { EditDataType, EditMetaData, ProjectDetailsType, Role } from '../../../frontend/src/shared/types' import { requireOneOf } from '../middlewares/authorizer' -import { Role } from '../../../frontend/src/shared/types' const router = Router() -router.post('/', requireOneOf([Role.Admin]), createProjectHandler) -router.put('/:id', requireOneOf([Role.Admin]), updateProjectHandler) +router.put( + '/', + requireOneOf([Role.Admin]), + async (req: Request & EditMetaData }>, res) => { + try { + const { ...editedProject } = req.body.project + const validationErrors = validateEntireProject({ ...editedProject }) + if (validationErrors.length > 0) { + return res.status(403).send(validationErrors) + } + const project = await writeProject(editedProject) + return res.status(200).send({ project }) + } catch (error) { + return res.status(500).send({ message: 'Failed to write project' }) + } + } +) export default router diff --git a/backend/src/services/project.ts b/backend/src/services/project.ts index 6f3172c3f..b3057cc2e 100644 --- a/backend/src/services/project.ts +++ b/backend/src/services/project.ts @@ -1,4 +1,6 @@ -import { Role, User } from '../../../frontend/src/shared/types' +import { EditDataType, EditMetaData, ProjectDetailsType, Role, User } from '../../../frontend/src/shared/types' +import { validateProject } from '../../../frontend/src/shared/validators/project' +import { ValidationObject } from '../../../frontend/src/shared/validators/validator' import { nowDb } from '../utils/db' export const getAllProjects = async (user?: User) => { @@ -20,7 +22,6 @@ export const getAllProjects = async (user?: User) => { export const getProjectDetails = async (id: number) => { // TODO: Check if user has access - const result = await nowDb.now_proj.findUnique({ where: { pid: id }, include: { @@ -61,3 +62,13 @@ export const deleteProject = async (projectId: number) => { return true } + +export const validateEntireProject = (editedFields: EditDataType & EditMetaData) => { + const keys = Object.keys(editedFields) + const errors: ValidationObject[] = [] + for (const key of keys) { + const error = validateProject(editedFields as EditDataType, key as keyof ProjectDetailsType) + if (error.error) errors.push(error) + } + return errors +} diff --git a/backend/src/services/write/project.ts b/backend/src/services/write/project.ts new file mode 100644 index 000000000..b7c441cd7 --- /dev/null +++ b/backend/src/services/write/project.ts @@ -0,0 +1,81 @@ +import { ensureValidMemberIds, ValidationError } from 'src/validators/projectsValidator' +import { EditDataType, ProjectDetailsType } from '../../../../frontend/src/shared/types' +import Prisma from '../../../prisma/generated/now_test_client' +import { getFieldsOfTables, nowDb } from '../../utils/db' +import { getProjectDetails } from '../project' +import { filterAllowedKeys, fixRadioSelection } from './writeOperations/utils' + +const loadMembersByIds = async (memberIds: number[]) => { + if (!memberIds.length) return [] as Array<{ initials: string; user_id: number }> + + const members = await nowDb.com_people.findMany({ + select: { initials: true, user_id: true }, + where: { user_id: { in: memberIds } }, + }) + + if (members.length !== memberIds.length) { + throw new ValidationError('One or more project members do not exist') + } + + return members +} + +export const writeProject = async (project: EditDataType) => { + const allowedColumns = getFieldsOfTables(['now_proj']) + const filteredProject = filterAllowedKeys(project, allowedColumns) as Prisma.now_proj + let projectId: number + + const contactPerson = await nowDb.com_people.findFirst({ where: { initials: project.contact } }) + if (!contactPerson) { + throw new ValidationError('Contact does not exist') + } + + const memberUserIds = project.now_proj_people + .filter(person => person.rowState !== 'removed') + .map(person => person.com_people?.user_id) + const uniqueMemberIds = ensureValidMemberIds(memberUserIds) + const members = await loadMembersByIds(uniqueMemberIds) + + const existingProject = await getProjectDetails(filteredProject.pid) + if (!existingProject) { + const newProject = await nowDb.now_proj.create({ + data: { + ...filteredProject, + proj_records: fixRadioSelection(filteredProject.proj_records), + now_proj_people: + members.length > 0 + ? { + create: members.map(member => ({ initials: member.initials })), + } + : undefined, + }, + include: { now_proj_people: true }, + }) + projectId = newProject.pid + } else { + await nowDb.$transaction(async prisma => { + await prisma.now_proj.update({ + where: { pid: filteredProject.pid }, + data: { + ...filteredProject, + proj_records: fixRadioSelection(filteredProject.proj_records), + }, + }) + + if (existingProject) { + // removes all members, then adds back ones that are not marked as removed + if (Array.isArray(members)) { + await prisma.now_proj_people.deleteMany({ where: { pid: filteredProject.pid } }) + if (members.length > 0) { + await prisma.now_proj_people.createMany({ + data: members.map(member => ({ pid: filteredProject.pid, initials: member.initials })), + }) + } + } + } + }) + projectId = filteredProject.pid + } + + return projectId +} diff --git a/backend/src/validators/projectsValidator.ts b/backend/src/validators/projectsValidator.ts index 74fd34f97..012eb1419 100644 --- a/backend/src/validators/projectsValidator.ts +++ b/backend/src/validators/projectsValidator.ts @@ -32,19 +32,19 @@ export const ensureNonEmptyString = (value: unknown, fieldName: string, maxLengt return trimmedValue } -export const ensureValidUserId = (value: unknown, fieldName: string) => { - if (typeof value !== 'number' || !Number.isInteger(value)) { +export const ensureValidUserId = (id: unknown, fieldName: string) => { + if (typeof id !== 'number' || !Number.isInteger(id)) { throw new ValidationError(`${fieldName} must be a valid number`) } - return value + return id } export const ensureValidMemberIds = (memberUserIds: unknown) => { - if (memberUserIds === undefined) return [] as number[] + if (memberUserIds === null) return [] as number[] if (!Array.isArray(memberUserIds)) { - throw new ValidationError('Project members must be an array of user IDs') + throw new ValidationError('Project members must be an array of persons') } const numericIds = memberUserIds.filter((id): id is number => typeof id === 'number' && Number.isInteger(id)) diff --git a/frontend/src/components/Project/ProjectDetails.tsx b/frontend/src/components/Project/ProjectDetails.tsx index 121103af6..43e866870 100755 --- a/frontend/src/components/Project/ProjectDetails.tsx +++ b/frontend/src/components/Project/ProjectDetails.tsx @@ -2,35 +2,26 @@ import { useMemo } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { useNotify } from '@/hooks/notification' -import { useUsersApi } from '@/hooks/useUsersApi' -import { - UpdateProjectPayload, - useDeleteProjectMutation, - useGetProjectDetailsQuery, - useUpdateProjectMutation, -} from '@/redux/projectReducer' -import { ProjectPeople, RowState } from '@/shared/types' +import { useDeleteProjectMutation, useGetProjectDetailsQuery, useUpdateProjectMutation } 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 type { EditDataType, ProjectDetailsType } from '@/shared/types' -import type { ValidationObject } from '@/shared/validators/validator' import type { FetchBaseQueryError } from '@reduxjs/toolkit/query' 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 [deleteProject, { isLoading: isDeleting }] = useDeleteProjectMutation() const [updateProject, { isLoading: isUpdating }] = useUpdateProjectMutation() const { notify } = useNotify() const navigate = useNavigate() - if (isError || isUsersError) return
Error loading data
- if (isLoading || !data || isDeleting || isUsersLoading || isUpdating) return + if (isError) return
Error loading data
+ if (isLoading || !data || isDeleting || isUpdating) return if (data) { document.title = `Project - ${data.proj_name}` } @@ -56,64 +47,11 @@ export const ProjectDetails = () => { }, ] - const validator = ( - _editData: EditDataType, - field: keyof EditDataType - ): ValidationObject => ({ - name: String(field), - error: null, - }) - - const createProjectUpdatePayload = (editData: EditDataType): UpdateProjectPayload | null => { - const userOption = users.find(user => user.initials === editData.contact) - if (!userOption) { - notify('Could not find selected coordinator.', 'error') - return null - } - - const isRemoved = (member: EditDataType) => { - const state = (member as EditDataType & { rowState?: RowState }).rowState - return state === 'removed' || state === 'cancelled' - } - - 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 users.find(user => user.initials === member.initials) ?? null - }) - .filter((id): id is number => typeof id === 'number') - ) - ) - return { - pid: editData.pid!, - projectCode: editData.proj_code!, - projectName: editData.proj_name!, - coordinatorUserId: userOption.userId, - projectStatus: editData.proj_status!, - recordStatus: editData.proj_records!, - memberUserIds, - } - } - const onWrite = async (editData: EditDataType) => { if (!projectId) return - const payload = createProjectUpdatePayload(editData) - if (!payload) { - return - } - - console.log(editData) - console.log(payload) - try { - await updateProject(payload).unwrap() + await updateProject(editData).unwrap() notify('Saved project successfully.') } catch (error) { const fetchError = error as FetchBaseQueryError @@ -131,5 +69,14 @@ export const ProjectDetails = () => { } } - return + return ( + + ) } diff --git a/frontend/src/redux/projectReducer.ts b/frontend/src/redux/projectReducer.ts index bf1cbe966..76cd89bb5 100755 --- a/frontend/src/redux/projectReducer.ts +++ b/frontend/src/redux/projectReducer.ts @@ -1,5 +1,5 @@ import { api } from './api' -import { Project, ProjectDetailsType } from '@/shared/types' +import { EditDataType, Project, ProjectDetailsType } from '@/shared/types' export type CreateProjectPayload = { projectCode: string @@ -34,11 +34,11 @@ const projectsApi = api.injectEndpoints({ }), invalidatesTags: ['projects'], }), - updateProject: builder.mutation({ - query: ({ pid, ...body }) => ({ - url: `/projects/${pid}`, + updateProject: builder.mutation>({ + query: project => ({ + url: `/projects`, method: 'PUT', - body, + body: { project }, }), invalidatesTags: (result, _error, { pid }) => result ? [{ type: 'project', id: pid }, 'projects'] : ['projects'], diff --git a/frontend/src/shared/validators/project.ts b/frontend/src/shared/validators/project.ts index 2622e0dba..a11fdd816 100644 --- a/frontend/src/shared/validators/project.ts +++ b/frontend/src/shared/validators/project.ts @@ -1,4 +1,4 @@ -import { EditDataType, Project } from '../types' +import { EditDataType, ProjectDetailsType } from '../types' import { Validators, validateFields, validator } from './validator' /* @@ -10,7 +10,7 @@ import { Validators, validateFields, validator } from './validator' proj_records: boolean | null; */ -const projectValidators: Validators>> = { +const projectValidators: Validators>> = { pid: { name: 'Project Id', required: true, @@ -39,16 +39,15 @@ const projectValidators: Validators>> = { proj_records: { name: 'Record Status', required: true, - asString: value => { - if (!['public', 'private'].includes(value)) return 'Record Status must be either true or false.' - return null - }, }, } -export const validateProject = (editData: EditDataType, fieldName: keyof EditDataType) => { - return validator>(projectValidators, editData, fieldName) +export const validateProject = ( + editData: EditDataType, + fieldName: keyof EditDataType +) => { + return validator>(projectValidators, editData, fieldName) } -export const validateProjectFields = (editData: Partial>) => - validateFields>(projectValidators, editData) +export const validateProjectFields = (editData: Partial>) => + validateFields>(projectValidators, editData) From 51d37ad217c115b26da5648a139c0f7d4c6ccc20 Mon Sep 17 00:00:00 2001 From: juhanikat Date: Wed, 5 Aug 2026 16:36:33 +0300 Subject: [PATCH 3/5] update project backend files, projectReducer, project validators and tests --- backend/src/api-tests/projects/data.ts | 18 +++ backend/src/api-tests/projects/update.test.ts | 125 +++++++----------- backend/src/api-tests/utils.ts | 6 +- backend/src/routes/projects.ts | 8 +- backend/src/services/write/project.ts | 4 +- frontend/src/redux/projectReducer.ts | 8 -- frontend/src/shared/validators/project.ts | 10 +- 7 files changed, 84 insertions(+), 95 deletions(-) create mode 100644 backend/src/api-tests/projects/data.ts diff --git a/backend/src/api-tests/projects/data.ts b/backend/src/api-tests/projects/data.ts new file mode 100644 index 000000000..73f19649b --- /dev/null +++ b/backend/src/api-tests/projects/data.ts @@ -0,0 +1,18 @@ +import { EditDataType, EditMetaData, ProjectDetailsType } from '../../../../frontend/src/shared/types' +export const newProjectBasis: EditDataType & EditMetaData = { + proj_name: 'LATER Database', + proj_code: 'LATER', + contact: 'AD', + proj_status: 'current', + proj_records: true, + now_proj_people: [], + references: [], + comment: '', +} + +export const editedProject: EditDataType & EditMetaData = { + ...newProjectBasis, + pid: 3, + proj_name: 'edited project name', + proj_code: 'LATER2', +} diff --git a/backend/src/api-tests/projects/update.test.ts b/backend/src/api-tests/projects/update.test.ts index 54d2d8b9c..c6f994d3c 100644 --- a/backend/src/api-tests/projects/update.test.ts +++ b/backend/src/api-tests/projects/update.test.ts @@ -1,102 +1,75 @@ -import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals' -import { login, logout, noPermError, resetDatabase, resetDatabaseTimeout, send } from '../utils' +import { afterAll, beforeAll, describe, expect, it } from '@jest/globals' +import { login, logout, noPermError, resetDatabase, send } from '../utils' import { pool } from '../../utils/db' +import { editedProject } from './data' +import { ProjectDetailsType } from '../../../../frontend/src/shared/types' -describe('PUT /projects/:id', () => { - beforeAll(async () => { - await resetDatabase() - }, resetDatabaseTimeout) +let existingProject: ProjectDetailsType | null = null - beforeEach(async () => { +describe('Updating project works', () => { + beforeAll(async () => { await resetDatabase() - await login('testSu', 'test') + await login() + const { body, status: getReqStat } = await send('project/3', 'GET') + expect(getReqStat).toEqual(200) + existingProject = body }) afterAll(async () => { await pool.end() }) - it('updates coordinator and members', async () => { - const createResult = await send<{ pid: number }>('projects', 'POST', { - projectCode: 'PRJ-UPDATE', - projectName: 'Project To Update', - coordinatorUserId: 163, - memberUserIds: [167], + it('Request succeeds and returns valid number id', async () => { + const { body: resultBody, status: updateStatus } = await send<{ pid: number }>('projects/', 'PUT', { + project: editedProject, }) - expect(createResult.status).toEqual(201) + const { pid: updatedId } = resultBody - const result = await send<{ - pid: number - contact: string - now_proj_people: Array<{ initials: string; pid: number }> - proj_name: string - proj_code: string - }>(`projects/${createResult.body.pid}`, 'PUT', { - projectCode: 'PRJ-UPD01', - projectName: 'Updated Project', - coordinatorUserId: 167, - memberUserIds: [163], - }) + expect(typeof updatedId).toEqual('number') + expect(updateStatus).toEqual(200) - expect(result.status).toEqual(200) - expect(result.body.contact).toEqual('TEST-PL') - expect(result.body.proj_code).toEqual('PRJ-UPD01') - expect(result.body.proj_name).toEqual('Updated Project') - expect(result.body.now_proj_people).toEqual([{ initials: 'TEST-SU', pid: result.body.pid }]) + const { body, status: getReqStat } = await send(`project/${updatedId}`, 'GET') + expect(getReqStat).toEqual(200) + existingProject = body }) - it('returns validation error for invalid members', async () => { - const createResult = await send<{ pid: number }>('projects', 'POST', { - projectCode: 'PRJ-INV1', - projectName: 'Project Invalid Members', - coordinatorUserId: 163, - }) - expect(createResult.status).toEqual(201) + it('Contains correct data', () => { + const { proj_code, proj_name } = existingProject! + expect(proj_code).toEqual(editedProject.proj_code) + expect(proj_name).toEqual(editedProject.proj_name) + }) - const result = await send(`projects/${createResult.body.pid}`, 'PUT', { - memberUserIds: ['not-a-number'], + it('Updating fails with empty project code', async () => { + const { body: resultBody, status: getReqStatus } = await send('projects/', 'PUT', { + project: { ...editedProject, proj_code: '' }, }) - - expect(result.status).toEqual(400) + expect(getReqStatus).toEqual(403) + expect(resultBody.length).toEqual(1) //There should be 1 validation error }) - it('returns validation error for too-long project code', async () => { - const createResult = await send<{ pid: number }>('projects', 'POST', { - projectCode: 'PRJ-LONG1', - projectName: 'Project Long Code', - coordinatorUserId: 163, + it('Updating succeeds fails for non-admin users', async () => { + await login('testEr') + const { body: resultBodyEr, status: resultStatusEr } = await send<{ pid: number }>('projects/', 'PUT', { + project: { ...editedProject, proj_code: 'CODE2' }, }) - expect(createResult.status).toEqual(201) + expect(resultBodyEr).toEqual(noPermError) + expect(resultStatusEr).toEqual(403) - const result = await send(`projects/${createResult.body.pid}`, 'PUT', { - projectCode: 'PRJ-UPDATED', + await login('testEu') + const { body: resultBodyEu, status: resultStatusEu } = await send<{ pid: number }>('projects/', 'PUT', { + project: { ...editedProject, proj_code: 'CODE3' }, }) - - expect(result.status).toEqual(400) - expect(result.body).toEqual({ message: 'Project code must be at most 10 characters' }) - }) - - it('denies non-admin users', async () => { - const unauthorizedUpdate = { projectName: 'Unauthorized Update' } - - await login('testPl', 'test') - const plResult = await send('projects/1', 'PUT', unauthorizedUpdate) - expect(plResult.status).toEqual(403) - expect(plResult.body).toEqual(noPermError) - - await login('testEu', 'test') - const euResult = await send('projects/1', 'PUT', unauthorizedUpdate) - expect(euResult.status).toEqual(403) - expect(euResult.body).toEqual(noPermError) - - await login('testEr', 'test') - const erResult = await send('projects/1', 'PUT', unauthorizedUpdate) - expect(erResult.status).toEqual(403) - expect(erResult.body).toEqual(noPermError) + expect(resultBodyEu).toEqual(noPermError) + expect(resultStatusEu).toEqual(403) logout() - const anonResult = await send('projects/1', 'PUT', unauthorizedUpdate) - expect(anonResult.status).toEqual(403) - expect(anonResult.body).toEqual(noPermError) + const { body: resultBodyAnon, status: resultStatusAnon } = await send('projects/', 'PUT', { + project: { ...editedProject, proj_code: 'CODE4' }, + }) + expect(resultBodyAnon).toEqual(noPermError) + expect(resultStatusAnon).toEqual(403) }) + + it.todo("Adding members works") + it.todo("Removing members works") }) diff --git a/backend/src/api-tests/utils.ts b/backend/src/api-tests/utils.ts index 310c67522..a349e3db0 100644 --- a/backend/src/api-tests/utils.ts +++ b/backend/src/api-tests/utils.ts @@ -23,7 +23,11 @@ export const send = async | Array 0) { return res.status(403).send(validationErrors) } - const project = await writeProject(editedProject) - return res.status(200).send({ project }) + const pid = await writeProject(editedProject) + return res.status(200).send({ pid }) } catch (error) { return res.status(500).send({ message: 'Failed to write project' }) } diff --git a/backend/src/services/write/project.ts b/backend/src/services/write/project.ts index b7c441cd7..e23476d3c 100644 --- a/backend/src/services/write/project.ts +++ b/backend/src/services/write/project.ts @@ -1,4 +1,4 @@ -import { ensureValidMemberIds, ValidationError } from 'src/validators/projectsValidator' +import { ensureValidMemberIds, ValidationError } from '../../validators/projectsValidator' import { EditDataType, ProjectDetailsType } from '../../../../frontend/src/shared/types' import Prisma from '../../../prisma/generated/now_test_client' import { getFieldsOfTables, nowDb } from '../../utils/db' @@ -36,7 +36,7 @@ export const writeProject = async (project: EditDataType) => const uniqueMemberIds = ensureValidMemberIds(memberUserIds) const members = await loadMembersByIds(uniqueMemberIds) - const existingProject = await getProjectDetails(filteredProject.pid) + const existingProject = filteredProject.pid ? await getProjectDetails(filteredProject.pid) : null if (!existingProject) { const newProject = await nowDb.now_proj.create({ data: { diff --git a/frontend/src/redux/projectReducer.ts b/frontend/src/redux/projectReducer.ts index 76cd89bb5..e983b2509 100755 --- a/frontend/src/redux/projectReducer.ts +++ b/frontend/src/redux/projectReducer.ts @@ -26,14 +26,6 @@ const projectsApi = api.injectEndpoints({ }), providesTags: result => (result ? [{ type: 'project', id: result.pid }] : []), }), - createProject: builder.mutation({ - query: body => ({ - url: `/projects`, - method: 'POST', - body, - }), - invalidatesTags: ['projects'], - }), updateProject: builder.mutation>({ query: project => ({ url: `/projects`, diff --git a/frontend/src/shared/validators/project.ts b/frontend/src/shared/validators/project.ts index a11fdd816..b9f4a9b99 100644 --- a/frontend/src/shared/validators/project.ts +++ b/frontend/src/shared/validators/project.ts @@ -11,10 +11,6 @@ import { Validators, validateFields, validator } from './validator' */ const projectValidators: Validators>> = { - pid: { - name: 'Project Id', - required: true, - }, contact: { name: 'Coordinator', required: true, @@ -22,6 +18,8 @@ const projectValidators: Validators>> = proj_code: { name: 'Project Code', required: true, + maxLength: 10, + asString: true, }, proj_name: { name: 'Project Name', @@ -40,6 +38,10 @@ const projectValidators: Validators>> = name: 'Record Status', required: true, }, + now_proj_people: { + name: 'Members', + required: true, + }, } export const validateProject = ( From 82ad9e311ccb26b8c8978b266584a94ede5148cc Mon Sep 17 00:00:00 2001 From: juhanikat Date: Thu, 6 Aug 2026 12:41:58 +0300 Subject: [PATCH 4/5] update project api tests, and error handling in ProjectDetails --- backend/src/api-tests/project/create.test.ts | 124 ++++++++++++++++++ backend/src/api-tests/project/data.ts | 70 ++++++++++ backend/src/api-tests/project/delete.test.ts | 69 ++++++---- .../{projects => project}/get.test.ts | 0 .../{projects => project}/update.test.ts | 4 +- backend/src/api-tests/projects/create.test.ts | 88 ------------- backend/src/api-tests/projects/data.ts | 18 --- backend/src/api-tests/utils.ts | 3 +- backend/src/services/project.ts | 9 +- backend/src/services/write/project.ts | 8 +- .../src/components/Project/ProjectDetails.tsx | 18 +-- 11 files changed, 249 insertions(+), 162 deletions(-) create mode 100644 backend/src/api-tests/project/create.test.ts create mode 100644 backend/src/api-tests/project/data.ts rename backend/src/api-tests/{projects => project}/get.test.ts (100%) rename backend/src/api-tests/{projects => project}/update.test.ts (97%) delete mode 100644 backend/src/api-tests/projects/create.test.ts delete mode 100644 backend/src/api-tests/projects/data.ts diff --git a/backend/src/api-tests/project/create.test.ts b/backend/src/api-tests/project/create.test.ts new file mode 100644 index 000000000..3d31036b3 --- /dev/null +++ b/backend/src/api-tests/project/create.test.ts @@ -0,0 +1,124 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals' +import { ProjectDetailsType } from '../../../../frontend/src/shared/types' +import { pool } from '../../utils/db' +import { login, logout, noPermError, resetDatabase, resetDatabaseTimeout, send } from '../utils' +import { + newProjectBasis, + newProjectBasisWithInvalidMembers, + newProjectBasisWithoutCoordinator, + newProjectBasisWithoutMemberArray, + noCoordinatorError, + noMemberArrayError, +} from './data' + +let createdProject: ProjectDetailsType | null = null + +describe('Creating new project works', () => { + beforeAll(async () => { + await resetDatabase() + }, resetDatabaseTimeout) + beforeEach(async () => { + await login() + }) + afterAll(async () => { + await pool.end() + }) + + it('Request succeeds and returns valid number id', async () => { + const { body: resultBody, status: getReqStatus } = await send<{ pid: number }>('projects/', 'PUT', { + project: newProjectBasis, + }) + const { pid: createdId } = resultBody + + expect(typeof createdId).toEqual('number') + expect(getReqStatus).toEqual(200) + + const { body, status: getReqStat } = await send(`project/${createdId}`, 'GET') + expect(getReqStat).toEqual(200) + createdProject = body + }) + + it('Contains correct data', () => { + const { pid, proj_name, proj_code, proj_status, proj_records } = createdProject! + expect(pid).toBeDefined() + expect(proj_name).toEqual(newProjectBasis.proj_name) + expect(proj_code).toEqual(newProjectBasis.proj_code) + expect(proj_status).toEqual(newProjectBasis.proj_status) + expect(proj_records).toEqual(newProjectBasis.proj_records) + }) + + it('Creation succeeds with empty member list', async () => { + const { status: getReqStatus } = await send<{ pid: number }>('projects/', 'PUT', { + project: { ...newProjectBasis, now_proj_people: [] }, + }) + + expect(getReqStatus).toEqual(200) + }) + + it('Creation fails with no member list', async () => { + const { body: resultBody, status: getReqStatus } = await send('projects/', 'PUT', { + project: { ...newProjectBasisWithoutMemberArray }, + }) + + //expect(typeof resultBody).toBe('array') + expect(resultBody).toContainEqual(noMemberArrayError) + expect(getReqStatus).toEqual(403) + }) + + it('Creation fails with member list containing members with no user ID information', async () => { + const { status: getReqStatus } = await send('projects/', 'PUT', { + project: { ...newProjectBasisWithInvalidMembers }, + }) + + expect(getReqStatus).toEqual(500) + }) + + it('Creation succeeds with empty member list', async () => { + const { status: getReqStatus } = await send<{ pid: number }>('projects/', 'PUT', { + project: { ...newProjectBasis, now_proj_people: [] }, + }) + + expect(getReqStatus).toEqual(200) + }) + + it('Creation fails with no coordinator', async () => { + const { body: resultBody, status: getReqStatus } = await send<{ pid: number }>('projects/', 'PUT', { + project: { ...newProjectBasisWithoutCoordinator }, + }) + + //expect(typeof resultBody).toBe('array') + expect(resultBody).toContainEqual(noCoordinatorError) + expect(getReqStatus).toEqual(403) + }) + + it('Creation fails with coordinator that does not have a matching user', async () => { + const { status: getReqStatus } = await send<{ pid: number }>('projects/', 'PUT', { + project: { ...newProjectBasis, contact: 'NOTEXIST' }, + }) + + expect(getReqStatus).toEqual(500) + }) + + it('Creation fails for non-admin users', async () => { + logout() + const { body: resultBodyAnon, status: resultStatusAnon } = await send<{ pid: number }>('projects/', 'PUT', { + project: newProjectBasis, + }) + expect(resultBodyAnon).toEqual(noPermError) + expect(resultStatusAnon).toEqual(403) + + await login('testEr') + const { body: resultBodyEr, status: resultStatusEr } = await send<{ pid: number }>('projects/', 'PUT', { + project: newProjectBasis, + }) + expect(resultBodyEr).toEqual(noPermError) + expect(resultStatusEr).toEqual(403) + + await login('testEu') + const { body: resultBodyEu, status: resultStatusEu } = await send<{ pid: number }>('projects/', 'PUT', { + project: newProjectBasis, + }) + expect(resultBodyEu).toEqual(noPermError) + expect(resultStatusEu).toEqual(403) + }) +}) diff --git a/backend/src/api-tests/project/data.ts b/backend/src/api-tests/project/data.ts new file mode 100644 index 000000000..25d672f9e --- /dev/null +++ b/backend/src/api-tests/project/data.ts @@ -0,0 +1,70 @@ +import { PersonDetailsType } from '../../../../frontend/src/shared/types' + +export const existingPerson: Omit = { + initials: 'AD', + first_name: 'adf', + surname: 'ads', + full_name: 'adf ads', + format: null, + email: 'email', + user_id: 156, + organization: 'organization', + country: 'Finland', + password_set: new Date('2024-05-22T00:00:00.000Z'), + used_morph: null, + used_now: null, + used_gene: null, + now_user_group: 'eu', +} + +export const newProjectBasis = { + proj_name: 'LATER Database', + proj_code: 'LATER', + contact: 'AD', + proj_status: 'current', + proj_records: true, + now_proj_people: [{ pid: 3, initials: 'AD', com_people: existingPerson }], + references: [], + comment: '', +} + +export const editedProject = { + ...newProjectBasis, + pid: 3, + proj_name: 'edited project name', + proj_code: 'LATER2', +} + +export const newProjectBasisWithoutMemberArray = { + proj_name: 'LATER Database', + proj_code: 'LATER', + contact: 'AD', + proj_status: 'current', + proj_records: true, + references: [], + comment: '', +} + +export const newProjectBasisWithInvalidMembers = { + proj_name: 'LATER Database', + proj_code: 'LATER', + contact: 'AD', + proj_status: 'current', + proj_records: true, + now_proj_people: [{ pid: 3, initials: 'AD' }], + references: [], + comment: '', +} + +export const newProjectBasisWithoutCoordinator = { + proj_name: 'LATER Database', + proj_code: 'LATER', + proj_status: 'current', + proj_records: true, + now_proj_people: [{ pid: 3, initials: 'AD' }], + references: [], + comment: '', +} + +export const noMemberArrayError = { name: 'Members', error: 'This field is required' } +export const noCoordinatorError = { name: 'Coordinator', error: 'This field is required' } diff --git a/backend/src/api-tests/project/delete.test.ts b/backend/src/api-tests/project/delete.test.ts index edaae868e..28191c9a0 100644 --- a/backend/src/api-tests/project/delete.test.ts +++ b/backend/src/api-tests/project/delete.test.ts @@ -1,65 +1,76 @@ -import { afterAll, beforeAll, describe, expect, it } from '@jest/globals' +import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals' import { login, logout, noPermError, resetDatabase, resetDatabaseTimeout, send } from '../utils' import { pool } from '../../utils/db' +import { ProjectDetailsType } from '../../../../frontend/src/shared/types' -describe('DELETE /project/:id', () => { +describe('Deleting project works', () => { beforeAll(async () => { await resetDatabase() }, resetDatabaseTimeout) + beforeEach(async () => { + await login() + }) + afterAll(async () => { await pool.end() }) it('allows admins to delete a project and related data', async () => { - await login('testSu', 'test') - - const createdProject = await send<{ pid: number }>('projects', 'POST', { - projectCode: 'DEL-001', - projectName: 'Project To Delete', - coordinatorUserId: 163, - memberUserIds: [167], - }) - - const deleted = await send(`project/${createdProject.body.pid}`, 'DELETE') + const { body: existingProject, status: getReqStatus } = await send('project/14', 'GET') + expect(getReqStatus).toEqual(200) + expect(existingProject.pid).toEqual(14) + const deleted = await send(`project/${existingProject.pid}`, 'DELETE') expect(deleted.status).toEqual(200) - const fetchAfterDelete = await send(`project/${createdProject.body.pid}`, 'GET') + const fetchAfterDelete = await send(`project/${existingProject.pid}`, 'GET') expect(fetchAfterDelete.status).toEqual(404) }) - it('rejects deletion attempts from non-admin users', async () => { + it('project members are not deleted along with the project', async () => { + const { body: existingProject, status: getReqStatus } = await send('project/3', 'GET') + expect(getReqStatus).toEqual(200) + expect(existingProject.pid).toEqual(3) + expect(existingProject.now_proj_people[0]).toHaveProperty('com_people') + await login('testSu', 'test') - const createdProject = await send<{ pid: number }>('projects', 'POST', { - projectCode: 'DEL-002', - projectName: 'Unauthorized Delete', - coordinatorUserId: 163, - }) + const deleted = await send(`project/${existingProject.pid}`, 'DELETE') + expect(deleted.status).toEqual(200) - await login('testPl', 'test') - const plDeleteAttempt = await send(`project/${createdProject.body.pid}`, 'DELETE') - expect(plDeleteAttempt.status).toEqual(403) - expect(plDeleteAttempt.body).toEqual(noPermError) + const memberFetchAfterDelete = await send( + `person/${existingProject.now_proj_people[0].com_people!.initials}`, + 'GET' + ) + expect(memberFetchAfterDelete.status).toEqual(200) + }) + + it('rejects deletion attempts from non-admin users', async () => { + const { body: existingProject, status: getReqStatus } = await send('project/23', 'GET') + expect(getReqStatus).toEqual(200) + expect(existingProject.pid).toEqual(23) await login('testEu', 'test') - const euDeleteAttempt = await send(`project/${createdProject.body.pid}`, 'DELETE') + const euDeleteAttempt = await send(`project/${existingProject.pid}`, 'DELETE') expect(euDeleteAttempt.status).toEqual(403) expect(euDeleteAttempt.body).toEqual(noPermError) await login('testEr', 'test') - const erDeleteAttempt = await send(`project/${createdProject.body.pid}`, 'DELETE') + const erDeleteAttempt = await send(`project/${existingProject.pid}`, 'DELETE') expect(erDeleteAttempt.status).toEqual(403) expect(erDeleteAttempt.body).toEqual(noPermError) logout() - const anonDeleteAttempt = await send(`project/${createdProject.body.pid}`, 'DELETE') + const anonDeleteAttempt = await send(`project/${existingProject.pid}`, 'DELETE') expect(anonDeleteAttempt.status).toEqual(403) expect(anonDeleteAttempt.body).toEqual(noPermError) await login('testSu', 'test') - const fetchAfterAttempts = await send(`project/${createdProject.body.pid}`, 'GET') - expect(fetchAfterAttempts.status).toEqual(200) - expect(fetchAfterAttempts.body).toHaveProperty('pid', createdProject.body.pid) + const { body: afterAttemptsBody, status: afterAttemptsStatus } = await send( + `project/${existingProject.pid}`, + 'GET' + ) + expect(afterAttemptsStatus).toEqual(200) + expect(afterAttemptsBody.pid).toEqual(existingProject.pid) }) }) diff --git a/backend/src/api-tests/projects/get.test.ts b/backend/src/api-tests/project/get.test.ts similarity index 100% rename from backend/src/api-tests/projects/get.test.ts rename to backend/src/api-tests/project/get.test.ts diff --git a/backend/src/api-tests/projects/update.test.ts b/backend/src/api-tests/project/update.test.ts similarity index 97% rename from backend/src/api-tests/projects/update.test.ts rename to backend/src/api-tests/project/update.test.ts index c6f994d3c..9dadd8674 100644 --- a/backend/src/api-tests/projects/update.test.ts +++ b/backend/src/api-tests/project/update.test.ts @@ -70,6 +70,6 @@ describe('Updating project works', () => { expect(resultStatusAnon).toEqual(403) }) - it.todo("Adding members works") - it.todo("Removing members works") + it.todo('Adding members works') + it.todo('Removing members works') }) diff --git a/backend/src/api-tests/projects/create.test.ts b/backend/src/api-tests/projects/create.test.ts deleted file mode 100644 index 83898c6d8..000000000 --- a/backend/src/api-tests/projects/create.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it } from '@jest/globals' -import { login, logout, noPermError, resetDatabase, resetDatabaseTimeout, send } from '../utils' -import { pool } from '../../utils/db' - -describe('POST /projects', () => { - beforeAll(async () => { - await resetDatabase() - }, resetDatabaseTimeout) - - afterAll(async () => { - await pool.end() - }) - - it('creates a project with members', async () => { - await login('testSu', 'test') - - const result = await send<{ pid: number; contact: string; now_proj_people: Array<{ initials: string }> }>( - 'projects', - 'POST', - { - projectCode: 'PRJ-001', - projectName: 'Integration Test Project', - coordinatorUserId: 163, - projectStatus: 'current', - recordStatus: true, - memberUserIds: [167], - } - ) - - expect(result.status).toEqual(201) - expect(result.body.contact).toEqual('TEST-SU') - expect(result.body.now_proj_people).toEqual([{ initials: 'TEST-PL', pid: result.body.pid }]) - }) - - it('returns validation error when coordinator is missing', async () => { - await login('testSu', 'test') - - const result = await send('projects', 'POST', { - projectCode: 'PRJ-002', - projectName: 'Missing Coordinator', - projectStatus: 'planned', - recordStatus: false, - }) - - expect(result.status).toEqual(400) - }) - - it('rejects unknown member ids', async () => { - await login('testSu', 'test') - - const result = await send('projects', 'POST', { - projectCode: 'PRJ-003', - projectName: 'Unknown Member', - coordinatorUserId: 163, - memberUserIds: [99999], - }) - - expect(result.status).toEqual(400) - }) - - it('denies non-admin users', async () => { - const unauthorizedProject = { - projectCode: 'PRJ-004', - projectName: 'Unauthorized Create', - coordinatorUserId: 163, - } - - await login('testPl', 'test') - const plResult = await send('projects', 'POST', unauthorizedProject) - expect(plResult.status).toEqual(403) - expect(plResult.body).toEqual(noPermError) - - await login('testEu', 'test') - const euResult = await send('projects', 'POST', unauthorizedProject) - expect(euResult.status).toEqual(403) - expect(euResult.body).toEqual(noPermError) - - await login('testEr', 'test') - const erResult = await send('projects', 'POST', unauthorizedProject) - expect(erResult.status).toEqual(403) - expect(erResult.body).toEqual(noPermError) - - logout() - const anonResult = await send('projects', 'POST', unauthorizedProject) - expect(anonResult.status).toEqual(403) - expect(anonResult.body).toEqual(noPermError) - }) -}) diff --git a/backend/src/api-tests/projects/data.ts b/backend/src/api-tests/projects/data.ts deleted file mode 100644 index 73f19649b..000000000 --- a/backend/src/api-tests/projects/data.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { EditDataType, EditMetaData, ProjectDetailsType } from '../../../../frontend/src/shared/types' -export const newProjectBasis: EditDataType & EditMetaData = { - proj_name: 'LATER Database', - proj_code: 'LATER', - contact: 'AD', - proj_status: 'current', - proj_records: true, - now_proj_people: [], - references: [], - comment: '', -} - -export const editedProject: EditDataType & EditMetaData = { - ...newProjectBasis, - pid: 3, - proj_name: 'edited project name', - proj_code: 'LATER2', -} diff --git a/backend/src/api-tests/utils.ts b/backend/src/api-tests/utils.ts index a349e3db0..d8e4dfed9 100644 --- a/backend/src/api-tests/utils.ts +++ b/backend/src/api-tests/utils.ts @@ -21,9 +21,8 @@ export const send = async | Array { } export const validateEntireProject = (editedFields: EditDataType & EditMetaData) => { - const keys = Object.keys(editedFields) - const errors: ValidationObject[] = [] - for (const key of keys) { - const error = validateProject(editedFields as EditDataType, key as keyof ProjectDetailsType) - if (error.error) errors.push(error) - } + const errors: ValidationObject[] = validateProjectFields(editedFields) return errors } diff --git a/backend/src/services/write/project.ts b/backend/src/services/write/project.ts index e23476d3c..9bb35b456 100644 --- a/backend/src/services/write/project.ts +++ b/backend/src/services/write/project.ts @@ -30,9 +30,13 @@ export const writeProject = async (project: EditDataType) => throw new ValidationError('Contact does not exist') } + if (!project.now_proj_people.every(member => member.com_people && member.com_people.user_id)) { + throw new ValidationError('One or more members in the member array do not have user IDs') + } + const memberUserIds = project.now_proj_people - .filter(person => person.rowState !== 'removed') - .map(person => person.com_people?.user_id) + .filter(member => member.rowState !== 'removed') + .map(member => member.com_people!.user_id) const uniqueMemberIds = ensureValidMemberIds(memberUserIds) const members = await loadMembersByIds(uniqueMemberIds) diff --git a/frontend/src/components/Project/ProjectDetails.tsx b/frontend/src/components/Project/ProjectDetails.tsx index 43e866870..842563ecc 100755 --- a/frontend/src/components/Project/ProjectDetails.tsx +++ b/frontend/src/components/Project/ProjectDetails.tsx @@ -9,7 +9,7 @@ import { DetailView, TabType } from '../DetailView/DetailView' import { CoordinatorTab } from './Tabs/CoordinatorTab' import type { EditDataType, ProjectDetailsType } from '@/shared/types' -import type { FetchBaseQueryError } from '@reduxjs/toolkit/query' +import { ValidationErrors } from '@/shared/types' export const ProjectDetails = () => { const { id } = useParams() @@ -53,19 +53,9 @@ export const ProjectDetails = () => { try { await updateProject(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') } } From 6db37be7e933d5473dfb3cd1c95d35ab0e6ffdf4 Mon Sep 17 00:00:00 2001 From: juhanikat Date: Thu, 6 Aug 2026 15:04:32 +0300 Subject: [PATCH 5/5] update frontend project creation form --- .../components/Project/ProjectCreatePage.tsx | 71 +++++----- .../src/components/Project/ProjectDetails.tsx | 6 +- .../components/Project/ProjectEditPage.tsx | 133 ------------------ .../src/components/Project/ProjectForm.tsx | 29 ++-- frontend/src/hooks/useProjectsApi.ts | 12 -- .../src/pages/projects/ProjectEditPage.tsx | 5 - frontend/src/redux/projectReducer.ts | 13 +- frontend/src/router/index.tsx | 7 - 8 files changed, 65 insertions(+), 211 deletions(-) delete mode 100644 frontend/src/components/Project/ProjectEditPage.tsx delete mode 100644 frontend/src/hooks/useProjectsApi.ts delete mode 100644 frontend/src/pages/projects/ProjectEditPage.tsx diff --git a/frontend/src/components/Project/ProjectCreatePage.tsx b/frontend/src/components/Project/ProjectCreatePage.tsx index 1f67b9d50..d205ea97a 100644 --- a/frontend/src/components/Project/ProjectCreatePage.tsx +++ b/frontend/src/components/Project/ProjectCreatePage.tsx @@ -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(null) useEffect(() => { document.title = 'New project' }, []) - const userOptions = useMemo(() => users, [users]) + if (personQueryIsLoading) { + return + } 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. - + ) diff --git a/frontend/src/components/Project/ProjectDetails.tsx b/frontend/src/components/Project/ProjectDetails.tsx index 842563ecc..9deea1b12 100755 --- a/frontend/src/components/Project/ProjectDetails.tsx +++ b/frontend/src/components/Project/ProjectDetails.tsx @@ -2,7 +2,7 @@ import { useMemo } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { useNotify } from '@/hooks/notification' -import { useDeleteProjectMutation, useGetProjectDetailsQuery, useUpdateProjectMutation } from '@/redux/projectReducer' +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' @@ -16,7 +16,7 @@ export const ProjectDetails = () => { const projectId = useMemo(() => (id ? parseInt(id) : null), [id]) const { isLoading, isError, 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() @@ -51,7 +51,7 @@ export const ProjectDetails = () => { if (!projectId) return try { - await updateProject(editData).unwrap() + await editProject(editData).unwrap() notify('Saved project successfully.') } catch (e) { const error = e as ValidationErrors diff --git a/frontend/src/components/Project/ProjectEditPage.tsx b/frontend/src/components/Project/ProjectEditPage.tsx deleted file mode 100644 index 1fd2136e9..000000000 --- a/frontend/src/components/Project/ProjectEditPage.tsx +++ /dev/null @@ -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(null) - - useEffect(() => { - if (project) { - document.title = `Edit project - ${project.proj_name ?? project.pid}` - } - }, [project]) - - if (!user.token) { - return ( - - ) - } - - if (user.role !== Role.Admin) { - return ( - - ) - } - - if (isLoading || !initialValues) { - return ( - - - Loading project... - - ) - } - - if (isError || !project || !initialValues) { - return ( - - - - - ) - } - - 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 ( - - - - Edit Project - - Update project information, coordinator, and members. - - - - - ) -} - -export default ProjectEditPage diff --git a/frontend/src/components/Project/ProjectForm.tsx b/frontend/src/components/Project/ProjectForm.tsx index 898888926..d343ad625 100644 --- a/frontend/src/components/Project/ProjectForm.tsx +++ b/frontend/src/components/Project/ProjectForm.tsx @@ -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 = ({ { + 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 = ({ (value.trim() !== '' ? true : 'Project name is required'), + })} error={Boolean(errors.projectName)} helperText={errors.projectName?.message} disabled={isSubmitting} @@ -188,7 +201,7 @@ export const ProjectForm = ({ value !== null && value !== undefined && value !== '' }} render={({ field }) => (