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 88674d8e2f..ab515fa57e 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; @@ -311,6 +312,7 @@ function initializationBeforeTests() { const createdProposal = result.createProposal; if (createdProposal) { secondCreatedProposalPk = createdProposal.primaryKey; + secondCreatedProposalId = createdProposal.proposalId; cy.updateProposal({ proposalPk: createdProposal.primaryKey, @@ -580,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(); @@ -683,6 +685,77 @@ 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, 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('Submit Mass Assignments'); + + 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(); + + //Do second one first as the first chevron tooltip covers the second one + 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'); + + cy.contains(secondCreatedProposalId) + .closest('tr') + .find('[data-testid="ChevronRightIcon"]') + .click(); + + 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'); + }); + it('Should be able to assign Fap members to proposals in existing Fap', () => { cy.assignProposalsToFaps({ fapInstruments: [ @@ -709,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(); @@ -768,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(); @@ -825,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(); @@ -1115,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(); @@ -1210,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(); @@ -1367,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(); @@ -1520,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(); diff --git a/apps/frontend/src/components/fap/Proposals/AssignFapMemberToProposalModal.tsx b/apps/frontend/src/components/fap/Proposals/AssignFapMemberToProposalModal.tsx index cff0327f8a..7a79bb200e 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,27 @@ const AssignFapMemberToProposalModal = ({ disabled={selectedParticipants.length === 0} data-cy="assign-selected-users" > - Update + Assign without Rankings + + + {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..2ea628b1b8 --- /dev/null +++ b/apps/frontend/src/components/fap/Proposals/MultiRankAssignmentDialog.tsx @@ -0,0 +1,178 @@ +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + TextField, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} 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" + > + Mass Assignments with Ranking + + + Please assign a unique rank to each user, Ranks must be whole numbers + and greater than 1. + + + + + + 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 + ) || + (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.isInteger(rank) ? 0 : rank, + } // If integer, set to 0 (invalid) + : user + ) + ); + }} + required + fullWidth + margin="none" + /> + + + ))} + +
+
+
+ + + + +
+ ); +};