From 3203b88b337c6804ade4568cd248c90ccaf456ca Mon Sep 17 00:00:00 2001 From: Thomas Cottee Meldrum Date: Tue, 2 Sep 2025 14:16:37 +0100 Subject: [PATCH 1/6] feat: Allow for rank assignment durning mass reviewer assignment --- .../src/datasources/postgres/FapDataSource.ts | 2 + .../mutations/AssignMembersToFapMutation.ts | 3 + apps/e2e/cypress/e2e/FAPs.cy.ts | 77 +++++++++ .../AssignFapMemberToProposalModal.tsx | 24 ++- .../Proposals/FapAssignedReviewersTable.tsx | 89 +++++++---- .../FapProposalsAndAssignmentsTable.tsx | 14 +- .../Proposals/MultiRankAssignmentDialog.tsx | 149 ++++++++++++++++++ 7 files changed, 323 insertions(+), 35 deletions(-) create mode 100644 apps/frontend/src/components/fap/Proposals/MultiRankAssignmentDialog.tsx diff --git a/apps/backend/src/datasources/postgres/FapDataSource.ts b/apps/backend/src/datasources/postgres/FapDataSource.ts index b9fc23fb1a..be9ae5f8b0 100644 --- a/apps/backend/src/datasources/postgres/FapDataSource.ts +++ b/apps/backend/src/datasources/postgres/FapDataSource.ts @@ -864,6 +864,7 @@ export default class PostgresFapDataSource implements FapDataSource { memberId: number; fapProposalId: number; questionaryId: number; + rank?: number; }[], fapId: number ) { @@ -875,6 +876,7 @@ export default class PostgresFapDataSource implements FapDataSource { fap_id: fapId, fap_proposal_id: assignment.fapProposalId, questionary_id: assignment.questionaryId, + rank: assignment.rank || null, })) ); diff --git a/apps/backend/src/resolvers/mutations/AssignMembersToFapMutation.ts b/apps/backend/src/resolvers/mutations/AssignMembersToFapMutation.ts index 2cebaa7aef..9bc2bda6d2 100644 --- a/apps/backend/src/resolvers/mutations/AssignMembersToFapMutation.ts +++ b/apps/backend/src/resolvers/mutations/AssignMembersToFapMutation.ts @@ -20,6 +20,9 @@ export class FapReviewAssignmentInput { @Field(() => Int) public proposalPk: number; + + @Field(() => Int, { nullable: true }) + public rank?: number; } @ArgsType() diff --git a/apps/e2e/cypress/e2e/FAPs.cy.ts b/apps/e2e/cypress/e2e/FAPs.cy.ts index fb72dd7ff2..96602ee5fd 100644 --- a/apps/e2e/cypress/e2e/FAPs.cy.ts +++ b/apps/e2e/cypress/e2e/FAPs.cy.ts @@ -195,6 +195,7 @@ let createdCallId: number; let firstCreatedProposalPk: number; let firstCreatedProposalId: string; let secondCreatedProposalPk: number; +let secondCreatedProposalId: string; let thirdCreatedProposalPk: number; let createdWorkflowId: number; let createdEsiTemplateId: number; @@ -309,6 +310,7 @@ function initializationBeforeTests() { const createdProposal = result.createProposal; if (createdProposal) { secondCreatedProposalPk = createdProposal.primaryKey; + secondCreatedProposalId = createdProposal.proposalId; cy.updateProposal({ proposalPk: createdProposal.primaryKey, @@ -681,6 +683,81 @@ context('Fap reviews tests', () => { cy.get('[index="0"]').children().contains(fapMembers.reviewer.lastName); }); + it('Officer should be able to assign ranks to reviewers during mass assignment', () => { + cy.assignProposalsToFaps({ + fapInstruments: [ + { instrumentId: newlyCreatedInstrumentId, fapId: createdFapId }, + ], + proposalPks: [firstCreatedProposalPk, secondCreatedProposalPk], + }); + + cy.assignReviewersToFap({ + fapId: createdFapId, + memberIds: [fapMembers.reviewer.id], + }); + + cy.assignReviewersToFap({ + fapId: createdFapId, + memberIds: [fapMembers.reviewer2.id], + }); + + cy.login('officer'); + cy.visit(`/FapPage/${createdFapId}?tab=3`); + + cy.get('[type="checkbox"]').first().check(); + + cy.get('[data-cy="assign-fap-members"]').click(); + + cy.get('[role="dialog"]') + .contains(fapMembers.reviewer.lastName) + .parent() + .find('input[type="checkbox"]') + .click(); + + cy.get('[role="dialog"]') + .contains(fapMembers.reviewer2.lastName) + .parent() + .find('input[type="checkbox"]') + .click(); + + cy.get('[data-cy="assign-selected-users-with-rank"]').click(); + + cy.contains('Assign Multiple Ranks'); + + cy.get(`[data-cy="rank-${fapMembers.reviewer.lastName}"]`) + .first() + .type('1'); + + cy.get(`[data-cy="rank-${fapMembers.reviewer2.lastName}"]`) + .first() + .type('2'); + + cy.get('[data-cy="save-ranks"]').click(); + + clickConfirmOk(); + + cy.contains(firstCreatedProposalId) + .closest('tr') + .find('[data-testid="ChevronRightIcon"]') + .click(); + + cy.contains(fapMembers.reviewer.lastName).parent().contains('1'); + cy.contains(fapMembers.reviewer2.lastName).parent().contains('2'); + + cy.contains(firstCreatedProposalId) + .closest('tr') + .find('[data-testid="ChevronRightIcon"]') + .click(); + + cy.contains(secondCreatedProposalId) + .closest('tr') + .find('[data-testid="ChevronRightIcon"]') + .click(); + + cy.contains(fapMembers.reviewer.lastName).parent().contains('1'); + cy.contains(fapMembers.reviewer2.lastName).parent().contains('2'); + }); + it('Should be able to assign Fap members to proposals in existing Fap', () => { cy.assignProposalsToFaps({ fapInstruments: [ diff --git a/apps/frontend/src/components/fap/Proposals/AssignFapMemberToProposalModal.tsx b/apps/frontend/src/components/fap/Proposals/AssignFapMemberToProposalModal.tsx index cff0327f8a..5b3e7bf112 100644 --- a/apps/frontend/src/components/fap/Proposals/AssignFapMemberToProposalModal.tsx +++ b/apps/frontend/src/components/fap/Proposals/AssignFapMemberToProposalModal.tsx @@ -9,8 +9,11 @@ import PeopleTable from 'components/user/PeopleTable'; import { BasicUserDetails, Maybe, Role } from 'generated/sdk'; import { useFapMembersData } from 'hooks/fap/useFapMembersData'; +import { MultiRankAssignmentDialog } from './MultiRankAssignmentDialog'; + export type FapAssignedMember = BasicUserDetails & { role?: Maybe>; + rank?: number | null; }; type AssignFapMemberToProposalModalProps = { @@ -44,6 +47,7 @@ const AssignFapMemberToProposalModal = ({ BasicUserDetails[] >([]); const { loadingMembers, FapMembersData } = useFapMembersData(fapId, false); + const [rankSelectorOpen, setRankSelectorOpen] = useState(false); useEffect(() => { if (proposalPks.length === 0) { @@ -98,8 +102,26 @@ const AssignFapMemberToProposalModal = ({ disabled={selectedParticipants.length === 0} data-cy="assign-selected-users" > - Update + Update without rank + + + {rankSelectorOpen && ( + + )} ); diff --git a/apps/frontend/src/components/fap/Proposals/FapAssignedReviewersTable.tsx b/apps/frontend/src/components/fap/Proposals/FapAssignedReviewersTable.tsx index ab2dbb5ca1..0a59100ad1 100644 --- a/apps/frontend/src/components/fap/Proposals/FapAssignedReviewersTable.tsx +++ b/apps/frontend/src/components/fap/Proposals/FapAssignedReviewersTable.tsx @@ -2,7 +2,10 @@ import MaterialTable from '@material-table/core'; import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered'; import RateReviewIcon from '@mui/icons-material/RateReview'; import Visibility from '@mui/icons-material/Visibility'; +import WarningRoundedIcon from '@mui/icons-material/WarningRounded'; +import { Tooltip } from '@mui/material'; import Box from '@mui/material/Box'; +import { useTheme } from '@mui/material/styles'; import React, { useCallback, useEffect, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; @@ -32,33 +35,6 @@ type FapAssignedReviewersTableProps = { updateView: (proposalPk: number) => Promise; }; -const assignmentColumns = [ - { - title: 'First name', - field: 'user.firstname', - }, - { - title: 'Last name', - field: 'user.lastname', - }, - { - title: 'Date assigned', - field: 'dateAssignedFormatted', - }, - { - title: 'Rank', - field: 'rank', - emptyValue: '-', - hidden: true, - }, - { title: 'Review status', field: 'review.status' }, - { - title: 'Grade', - field: 'review.grade', - emptyValue: '-', - }, -]; - const FapAssignedReviewersTable = ({ fapProposal, fapSecs, @@ -66,6 +42,7 @@ const FapAssignedReviewersTable = ({ updateView, }: FapAssignedReviewersTableProps) => { const { api } = useDataApiWithFeedback(); + const theme = useTheme(); const [searchParams, setSearchParams] = useSearchParams(); const reviewerModal = searchParams.get('reviewerModal'); @@ -94,10 +71,6 @@ const FapAssignedReviewersTable = ({ PROPOSAL_MODAL_TAB_NAMES.GRADE, ]; - assignmentColumns[ - assignmentColumns.findIndex((col) => col.field === 'rank') - ].hidden = !hasAccessRights; - const fapAssignmentsStringified = JSON.stringify(fapProposal.assignments); const getFapAssignments = useCallback( () => @@ -128,6 +101,60 @@ const FapAssignedReviewersTable = ({ setFapAssignmentsWithIdAndFormattedDate(getFapAssignments()); }, [getFapAssignments]); + const assignmentColumns = [ + { + title: 'First name', + field: 'user.firstname', + }, + { + title: 'Last name', + field: 'user.lastname', + }, + { + title: 'Date assigned', + field: 'dateAssignedFormatted', + }, + { + title: 'Rank', + field: 'rank', + emptyValue: '-', + hidden: !hasAccessRights, + render: (rowData: FapProposalAssignmentType) => { + if ( + rowData.rank !== null && + fapAssignmentsWithIdAndFormattedDate.some( + (fa) => fa.user?.id !== rowData.user?.id && fa.rank === rowData.rank + ) + ) { + return ( + + + + {rowData.rank} + + + ); + } + + return {rowData.rank}; + }, + }, + { title: 'Review status', field: 'review.status' }, + { + title: 'Grade', + field: 'review.grade', + emptyValue: '-', + }, + ]; + const proposalReviewModalShouldOpen = !!reviewerModal && openProposalPk === fapProposal.proposalPk; diff --git a/apps/frontend/src/components/fap/Proposals/FapProposalsAndAssignmentsTable.tsx b/apps/frontend/src/components/fap/Proposals/FapProposalsAndAssignmentsTable.tsx index 130421a21a..442764131d 100644 --- a/apps/frontend/src/components/fap/Proposals/FapProposalsAndAssignmentsTable.tsx +++ b/apps/frontend/src/components/fap/Proposals/FapProposalsAndAssignmentsTable.tsx @@ -311,7 +311,11 @@ const FapProposalsAndAssignmentsTable = ({ (assignment) => assignment.assignments ); - const proposalAssignments: { memberId: number; proposalPk: number }[] = []; + const proposalAssignments: { + memberId: number; + proposalPk: number; + rank?: number | null; + }[] = []; const updatedMembers = new Set(); for (const proposalPk of proposalPks) { @@ -322,7 +326,11 @@ const FapProposalsAndAssignmentsTable = ({ proposalPk === existingProposalAssignment.proposalPk ); if (!isExistingAssignment) { - proposalAssignments.push({ memberId: assignedMember.id, proposalPk }); + proposalAssignments.push({ + memberId: assignedMember.id, + proposalPk, + rank: assignedMember.rank ?? null, + }); updatedMembers.add(assignedMember); } } @@ -406,7 +414,7 @@ const FapProposalsAndAssignmentsTable = ({ dateAssigned: DateTime.now(), user, role, - rank: null, + rank: user.rank ?? null, review: allProposalReviews.find( (review) => diff --git a/apps/frontend/src/components/fap/Proposals/MultiRankAssignmentDialog.tsx b/apps/frontend/src/components/fap/Proposals/MultiRankAssignmentDialog.tsx new file mode 100644 index 0000000000..3f1e40dcbf --- /dev/null +++ b/apps/frontend/src/components/fap/Proposals/MultiRankAssignmentDialog.tsx @@ -0,0 +1,149 @@ +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + TextField, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, +} from '@mui/material'; +import { useTheme } from '@mui/material/styles'; +import React, { ChangeEvent } from 'react'; + +import { BasicUserDetails } from 'generated/sdk'; + +import { FapAssignedMember } from './AssignFapMemberToProposalModal'; + +type MultiRankAssignmentDialogProps = { + users: BasicUserDetails[]; + open: boolean; + setOpen: (open: boolean) => void; + assign: (usersWithRank: FapAssignedMember[]) => void; +}; + +type BasicUserDetailsWithRank = BasicUserDetails & { + rank: number | null; // Rank can be null if not assigned +}; + +export const MultiRankAssignmentDialog = ({ + users, + open, + setOpen, + assign, +}: MultiRankAssignmentDialogProps) => { + const theme = useTheme(); + + const [usersWithRank, setUsersWithRank] = React.useState< + BasicUserDetailsWithRank[] + >( + users.map((user) => ({ + ...user, + rank: null, + })) + ); + + return ( + {}} + maxWidth="sm" + fullWidth + data-cy="multi-rank-assignment-dialog" + > + Assign Multiple Ranks + + + + + + First Name + Surname + Rank + + + + {usersWithRank.map((row) => ( + + user.id !== row.id && + user.rank === row.rank && + row.rank !== null + ) + ? theme.palette.error.light + : 'inherit', + }} + > + + {row.firstname} + + {row.lastname} + + + user.id !== row.id && + user.rank === row.rank && + row.rank !== null + )} + //TOTO Highlight conflivting ranks + sx={{ width: 100 }} + onChange={(e: ChangeEvent) => { + setUsersWithRank((prev) => + prev.map((user) => + user.id === row.id + ? { ...user, rank: Number(e.target.value) } + : user + ) + ); + }} + required + fullWidth + margin="none" + /> + + + ))} + +
+
+
+ + + + +
+ ); +}; + +// export default withConfirm(MultiRankAssignmentDialog); From e692d303c2cee8f0c6be534191d6a6f527806e0f Mon Sep 17 00:00:00 2001 From: Thomas Cottee Meldrum Date: Mon, 8 Sep 2025 11:10:30 +0100 Subject: [PATCH 2/6] feat: non numeric grades for faps WIP --- .../src/components/fap/Proposals/MultiRankAssignmentDialog.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/frontend/src/components/fap/Proposals/MultiRankAssignmentDialog.tsx b/apps/frontend/src/components/fap/Proposals/MultiRankAssignmentDialog.tsx index 3f1e40dcbf..42945e4fb8 100644 --- a/apps/frontend/src/components/fap/Proposals/MultiRankAssignmentDialog.tsx +++ b/apps/frontend/src/components/fap/Proposals/MultiRankAssignmentDialog.tsx @@ -134,7 +134,6 @@ export const MultiRankAssignmentDialog = ({ {rankSelectorOpen && ( - Assign Multiple Ranks + Mass Assignments with Ranking + + Please assign a unique rank to each user, Ranks must be whole numbers + and greater than 1. + @@ -90,22 +100,32 @@ export const MultiRankAssignmentDialog = ({ - user.id !== row.id && - user.rank === row.rank && - row.rank !== null - )} + error={ + usersWithRank.some( + (user) => + user.id !== row.id && + user.rank === row.rank && + row.rank !== null + ) || + (row.rank !== null && row.rank < 1) || + (row.rank !== null && isNaN(row.rank)) + } sx={{ width: 100 }} onChange={(e: ChangeEvent) => { + const rank = Number(e.target.value); // Non numeric values become 0 + setUsersWithRank((prev) => prev.map((user) => user.id === row.id - ? { ...user, rank: Number(e.target.value) } + ? { + ...user, + rank: !Number.isInteger(rank) ? 0 : rank, + } // If integer, set to 0 (invalid) : user ) ); @@ -132,12 +152,25 @@ export const MultiRankAssignmentDialog = ({ From 3331e9ae9d6dd74147782f095c8aba72bcb64185 Mon Sep 17 00:00:00 2001 From: Thomas Cottee Meldrum Date: Thu, 16 Oct 2025 17:13:21 +0100 Subject: [PATCH 6/6] fix tests --- apps/e2e/cypress/e2e/FAPs.cy.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/e2e/cypress/e2e/FAPs.cy.ts b/apps/e2e/cypress/e2e/FAPs.cy.ts index 6998b36642..ab515fa57e 100644 --- a/apps/e2e/cypress/e2e/FAPs.cy.ts +++ b/apps/e2e/cypress/e2e/FAPs.cy.ts @@ -582,7 +582,7 @@ context('Fap reviews tests', () => { .find('input[type="checkbox"]') .click(); cy.contains('1 user(s) selected'); - cy.contains('Update').click(); + cy.get('[data-cy="assign-selected-users"]').click(); clickConfirmOk(); @@ -719,7 +719,7 @@ context('Fap reviews tests', () => { cy.get('[data-cy="assign-selected-users-with-rank"]').click(); - cy.contains('Assign Multiple Ranks'); + cy.contains('Submit Mass Assignments'); cy.get(`[data-cy="rank-${fapMembers.reviewer.lastName}"]`) .first() @@ -782,7 +782,7 @@ context('Fap reviews tests', () => { cy.get('[role="dialog"]').find('input[type="checkbox"]').first().click(); cy.contains('2 user(s) selected'); - cy.contains('Update').click(); + cy.get('[data-cy="assign-selected-users"]').click(); clickConfirmOk(); @@ -841,7 +841,7 @@ context('Fap reviews tests', () => { .find('input[type="checkbox"]') .click(); cy.contains('1 user(s) selected'); - cy.contains('Update').click(); + cy.get('[data-cy="assign-selected-users"]').click(); clickConfirmOk(); @@ -898,7 +898,7 @@ context('Fap reviews tests', () => { cy.get('[role="dialog"]').find('input[type="checkbox"]').first().click(); cy.contains('2 user(s) selected'); - cy.contains('Update').click(); + cy.get('[data-cy="assign-selected-users"]').click(); clickConfirmOk(); @@ -1188,7 +1188,7 @@ context('Fap reviews tests', () => { .find('input[type="checkbox"]') .click(); - cy.contains('Update').click(); + cy.get('[data-cy="assign-selected-users"]').click(); clickConfirmOk(); @@ -1283,7 +1283,7 @@ context('Fap reviews tests', () => { .find('input[type="checkbox"]') .click(); cy.contains('1 user(s) selected'); - cy.contains('Update').click(); + cy.get('[data-cy="assign-selected-users"]').click(); clickConfirmOk(); @@ -1440,7 +1440,7 @@ context('Fap reviews tests', () => { .find('input[type="checkbox"]') .click(); cy.contains('1 user(s) selected'); - cy.contains('Update').click(); + cy.get('[data-cy="assign-selected-users"]').click(); clickConfirmOk(); @@ -1593,7 +1593,7 @@ context('Fap reviews tests', () => { .find('input[type="checkbox"]') .click(); cy.contains('1 user(s) selected'); - cy.contains('Update').click(); + cy.get('[data-cy="assign-selected-users"]').click(); clickConfirmOk();