diff --git a/apps/backend/src/mutations/ProposalMutations.ts b/apps/backend/src/mutations/ProposalMutations.ts index a649cee036..7a66d41fe0 100644 --- a/apps/backend/src/mutations/ProposalMutations.ts +++ b/apps/backend/src/mutations/ProposalMutations.ts @@ -546,7 +546,11 @@ export default class ProposalMutations { agent: UserWithRole | null, args: ChangeProposalsStatusInput ): Promise { - const { workflowStatusId: statusId, proposalPks } = args; + const { + workflowStatusId: statusId, + proposalPks, + workflowConnectionId, + } = args; const result = await this.proposalDataSource.changeProposalsWorkflowStatus( statusId, @@ -554,43 +558,48 @@ export default class ProposalMutations { ); if (result.proposals.length === proposalPks.length) { - const fullProposals = await Promise.all( - proposalPks.map(async (proposalPk) => { - const fullProposal = result.proposals.find( - (updatedProposal) => updatedProposal.primaryKey === proposalPk - ); - - if (!fullProposal) { - return null; - } - - const proposalWorkflow = - await this.callDataSource.getProposalWorkflowByCall( - fullProposal.callId + // Only run status actions if a specific workflow connection was provided + if (workflowConnectionId) { + const fullProposals = await Promise.all( + proposalPks.map(async (proposalPk) => { + const fullProposal = result.proposals.find( + (updatedProposal) => updatedProposal.primaryKey === proposalPk ); - if (!proposalWorkflow) { - return rejection( - `No propsal workflow found for the specific proposal call with id: ${fullProposal.callId}`, - { - agent, - args, - } - ); - } + if (!fullProposal) { + return null; + } - return { - ...fullProposal, - }; - }) - ); + const proposalWorkflow = + await this.callDataSource.getProposalWorkflowByCall( + fullProposal.callId + ); + + if (!proposalWorkflow) { + return rejection( + `No propsal workflow found for the specific proposal call with id: ${fullProposal.callId}`, + { + agent, + args, + } + ); + } - const statusEngineReadyProposals = fullProposals.filter( - (item): item is WorkflowEngineProposalType => !!item - ); + return { + ...fullProposal, + prevStatusId: fullProposal.workflowStatusId, + workflowStatusConnectionId: workflowConnectionId, + }; + }) + ); - // NOTE: After proposal status change we need to run the status engine and execute the actions on the selected status. - proposalStatusActionEngine(statusEngineReadyProposals); + const statusEngineReadyProposals = fullProposals.filter( + (item): item is WorkflowEngineProposalType => !!item + ); + + // NOTE: After proposal status change we need to run the status engine and execute the actions on the selected status. + proposalStatusActionEngine(statusEngineReadyProposals); + } } else { rejection('Could not change statuses to all of the selected proposals', { result, diff --git a/apps/backend/src/resolvers/mutations/ChangeProposalsStatusMutation.ts b/apps/backend/src/resolvers/mutations/ChangeProposalsStatusMutation.ts index 5be68c951e..3528acde9b 100644 --- a/apps/backend/src/resolvers/mutations/ChangeProposalsStatusMutation.ts +++ b/apps/backend/src/resolvers/mutations/ChangeProposalsStatusMutation.ts @@ -18,6 +18,9 @@ export class ChangeProposalsStatusInput { @Field(() => [Int]) public proposalPks: number[]; + + @Field(() => Int, { nullable: true }) + public workflowConnectionId?: number; } @Resolver() diff --git a/apps/e2e/cypress/e2e/proposals.cy.ts b/apps/e2e/cypress/e2e/proposals.cy.ts index 473b789f22..e3f9431088 100644 --- a/apps/e2e/cypress/e2e/proposals.cy.ts +++ b/apps/e2e/cypress/e2e/proposals.cy.ts @@ -2,9 +2,11 @@ import { faker } from '@faker-js/faker'; import { AllocationTimeUnits, DataType, + EmailStatusActionRecipients, FeatureId, ProposalEndStatus, SettingsId, + StatusActionType, TemplateCategoryId, TemplateGroupId, WorkflowType, @@ -126,6 +128,7 @@ context('Proposal tests', () => { cy.addStatusToWorkflow({ statusId: initialDBData.proposalStatuses.fapMeeting.id, workflowId: initialDBData.workflows.defaultWorkflow.id, + posY: 100, }).then((workflowStatusResult) => { if (workflowStatusResult.addStatusToWorkflow) { createdFapMeetingWorkflowStatusId = @@ -141,6 +144,7 @@ context('Proposal tests', () => { cy.addStatusToWorkflow({ statusId: initialDBData.proposalStatuses.feasibilityReview.id, workflowId: result.createWorkflow.id, + posY: 200, }); createdWorkflowId = result.createWorkflow.id; } @@ -880,6 +884,79 @@ context('Proposal tests', () => { ); }); + it('User officer should be able to opt-in to run status actions when changing status', () => { + const statusActionConfig = { + recipientsWithEmailTemplate: [ + { + recipient: { + name: EmailStatusActionRecipients.PI, + description: '', + }, + emailTemplate: { + id: initialDBData.emailTemplates.template1.id, + name: initialDBData.emailTemplates.template1.name, + }, + combineEmails: true, + }, + ], + }; + + // Add a status with a connection from DRAFT and attach a status action + cy.addStatusToWorkflow({ + statusId: initialDBData.proposalStatuses.feasibilityReview.id, + workflowId: initialDBData.workflows.defaultWorkflow.id, + prevId: + initialDBData.workflows.defaultWorkflow.workflowStatuses.draft.id, + posX: 0, + posY: 200, + }).then((result) => { + cy.addConnectionStatusActions({ + actions: [ + { + actionId: 1, + actionType: StatusActionType.EMAIL, + config: JSON.stringify(statusActionConfig), + }, + ], + connectionId: result.createWorkflowConnection.id, + workflowId: initialDBData.workflows.defaultWorkflow.id, + }); + }); + + cy.login('officer'); + cy.visit('/'); + + cy.contains(newProposalTitle).parent().find('[type="checkbox"]').check(); + + cy.get('[data-cy="change-proposal-status"]').click(); + + cy.finishedLoading(); + + cy.get('[role="presentation"] .MuiDialogContent-root').as('dialog'); + + // Select the status that has a connection with actions + cy.get('@dialog').find('#selectedWorkflowStatusId-input').click(); + cy.get('[role="listbox"]') + .contains(initialDBData.proposalStatuses.feasibilityReview.name) + .click(); + + // The run status actions checkbox should appear and be unchecked + cy.get('[data-cy="run-status-actions-checkbox"] input') + .should('exist') + .should('not.be.checked'); + + // Check it to opt-in to running status actions + cy.get('[data-cy="run-status-actions-checkbox"] input').check(); + + // Should be able to submit the status change + cy.get('[data-cy="submit-proposal-status-change"]').click(); + + cy.notification({ + variant: 'success', + text: 'status changed successfully', + }); + }); + it('Should be able to delete proposal', () => { cy.login('user1', initialDBData.roles.user); cy.visit('/'); diff --git a/apps/frontend/src/components/proposal/ChangeProposalStatus.tsx b/apps/frontend/src/components/proposal/ChangeProposalStatus.tsx index 12175e5cb2..a6a10b0f77 100644 --- a/apps/frontend/src/components/proposal/ChangeProposalStatus.tsx +++ b/apps/frontend/src/components/proposal/ChangeProposalStatus.tsx @@ -1,10 +1,16 @@ import Alert from '@mui/material/Alert'; import Button from '@mui/material/Button'; +import Checkbox from '@mui/material/Checkbox'; import Container from '@mui/material/Container'; +import FormControl from '@mui/material/FormControl'; +import FormControlLabel from '@mui/material/FormControlLabel'; import Grid from '@mui/material/Grid'; +import InputLabel from '@mui/material/InputLabel'; +import MenuItem from '@mui/material/MenuItem'; +import Select from '@mui/material/Select'; import Typography from '@mui/material/Typography'; import { Form, Formik } from 'formik'; -import React, { useMemo } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import * as yup from 'yup'; @@ -12,10 +18,15 @@ import i18n from 'i18n'; import FormikUIAutocomplete from 'components/common/FormikUIAutocomplete'; import WorkflowView from 'components/settings/workflow/WorkflowView'; -import { WorkflowStatus, WorkflowType } from 'generated/sdk'; +import { GetWorkflowQuery, WorkflowStatus, WorkflowType } from 'generated/sdk'; +import { useDataApi } from 'hooks/common/useDataApi'; import { ProposalViewData } from 'hooks/proposal/useProposalsCoreData'; import { useWorkflowStatusesData } from 'hooks/settings/useWorkflowStatusesData'; +type WorkflowConnectionWithDetails = NonNullable< + GetWorkflowQuery['workflow'] +>['connections'][0]; + const changeProposalStatusValidationSchema = yup.object().shape({ selectedWorkflowStatusId: yup .string() @@ -24,7 +35,10 @@ const changeProposalStatusValidationSchema = yup.object().shape({ type ChangeProposalStatusProps = { close: () => void; - changeStatusOnProposals: (workflowStatus: WorkflowStatus) => Promise; + changeStatusOnProposals: ( + workflowStatus: WorkflowStatus, + workflowConnectionId?: number + ) => Promise; selectedProposals: ProposalViewData[]; }; @@ -43,11 +57,20 @@ const ChangeProposalStatus = ({ (p) => p.workflowId ); const { t } = useTranslation(); + const api = useDataApi(); const { statuses: proposalStatuses, loadingStatuses: loadingProposalStatuses, } = useWorkflowStatusesData(selectedProposalsWorkflowIds[0]); + const [runStatusActions, setRunStatusActions] = useState(false); + const [connections, setConnections] = useState< + WorkflowConnectionWithDetails[] + >([]); + const [selectedConnectionId, setSelectedConnectionId] = useState< + number | null + >(null); + const allSelectedProposalsHaveSameWorkflowStatus = selectedProposalStatuses.every( (item) => item === selectedProposalStatuses[0] @@ -62,6 +85,22 @@ const ChangeProposalStatus = ({ ? selectedProposalStatuses[0] : null; + // Fetch workflow connections when the component mounts + useEffect(() => { + if (!selectedProposalsWorkflowIds[0]) return; + + api() + .getWorkflow({ + workflowId: selectedProposalsWorkflowIds[0], + entityType: WorkflowType.PROPOSAL, + }) + .then((data) => { + if (data.workflow?.connections) { + setConnections(data.workflow.connections); + } + }); + }, [api, selectedProposalsWorkflowIds[0]]); + const highlightedNodes = useMemo(() => { const counts = selectedProposals.reduce( (acc, proposal) => { @@ -87,6 +126,25 @@ const ChangeProposalStatus = ({ })); }, [selectedProposals, proposalStatuses]); + const getConnectionsToStatus = ( + workflowStatusId: number | null + ): WorkflowConnectionWithDetails[] => { + if (!workflowStatusId) return []; + + return connections.filter( + (conn) => conn.nextWorkflowStatusId === workflowStatusId + ); + }; + + const getConnectionLabel = (conn: WorkflowConnectionWithDetails): string => { + const actionTypes = conn.statusActions + ?.map((a) => a.action.type) + .join(', '); + const actionSummary = actionTypes ? ` [${actionTypes}]` : ' [no actions]'; + + return `From "${conn.prevStatus.status.name}"${actionSummary}`; + }; + if (!allProposalsHaveSameWorkflow) { return ( @@ -126,116 +184,233 @@ const ChangeProposalStatus = ({ return; } - await changeStatusOnProposals(selectedStatus); + await changeStatusOnProposals( + selectedStatus, + runStatusActions && selectedConnectionId + ? selectedConnectionId + : undefined + ); close(); }} validationSchema={changeProposalStatusValidationSchema} > - {({ isSubmitting, values, setFieldValue }): JSX.Element => ( -
- - - - Change proposal(s) status - - + {({ isSubmitting, values, setFieldValue }): JSX.Element => { + const incomingConnections = getConnectionsToStatus( + values.selectedWorkflowStatusId + ); + const connectionsWithActions = incomingConnections.filter( + (conn) => conn.statusActions && conn.statusActions.length > 0 + ); - -
- - s.workflowStatusId === values.selectedWorkflowStatusId - )?.status.id - } - onNodeClicked={(statusId, workflowStatusId) => { - setFieldValue( - 'selectedWorkflowStatusId', - workflowStatusId - ); + return ( + + + + -
-
+ > + Change proposal(s) status + +
- - - - ({ - value: status.workflowStatusId, - text: status.status.name, - }))} - required - disabled={isSubmitting} - data-cy="status-selection" + +
+ + s.workflowStatusId === + values.selectedWorkflowStatusId + )?.status.id + } + onNodeClicked={(statusId, workflowStatusId) => { + setFieldValue( + 'selectedWorkflowStatusId', + workflowStatusId + ); + setRunStatusActions(false); + setSelectedConnectionId(null); + }} /> - +
+
+ + + + + ({ + value: status.workflowStatusId, + text: status.status.name, + }))} + required + disabled={isSubmitting} + data-cy="status-selection" + onChange={(_: React.SyntheticEvent, value: number) => { + setFieldValue('selectedWorkflowStatusId', value); + setRunStatusActions(false); + setSelectedConnectionId(null); + }} + /> + + + {values.selectedWorkflowStatusId && + connectionsWithActions.length > 0 && ( + + { + setRunStatusActions(e.target.checked); + if (!e.target.checked) { + setSelectedConnectionId(null); + } else if ( + connectionsWithActions.length === 1 + ) { + setSelectedConnectionId( + connectionsWithActions[0].id + ); + } + }} + data-cy="run-status-actions-checkbox" + /> + } + label="Run status actions" + /> + + {runStatusActions && + connectionsWithActions.length > 1 && ( + + + Select transition + + + + )} + + {runStatusActions && selectedConnectionId && ( + + {(() => { + const conn = connectionsWithActions.find( + (c) => c.id === selectedConnectionId + ); + if (!conn?.statusActions?.length) return null; + + return ( + <> + Actions to execute: +
    + {conn.statusActions.map((action) => ( +
  • + {action.action.name} ( + {action.action.type}) +
  • + ))} +
+ + ); + })()} +
+ )} +
+ )} - - {proposalStatuses.find( - (status) => - status.workflowStatusId === - values.selectedWorkflowStatusId - )?.statusId === 'DRAFT' && ( - - Be aware that changing status to "DRAFT" will - reopen proposal for changes and submission. - - )} - {proposalStatuses.find( - (status) => - status.workflowStatusId === - values.selectedWorkflowStatusId - )?.statusId === 'SCHEDULING' && - !allSelectedProposalsHaveInstrument && ( + + {proposalStatuses.find( + (status) => + status.workflowStatusId === + values.selectedWorkflowStatusId + )?.statusId === 'DRAFT' && ( - {`Be aware that proposal/s not assigned to an ${i18n.format( - t('instrument'), - 'lowercase' - )} will not be shown in the scheduler after changing status to "SCHEDULING"`} + Be aware that changing status to "DRAFT" + will reopen proposal for changes and submission. )} - {!values.selectedWorkflowStatusId && ( - + status.workflowStatusId === + values.selectedWorkflowStatusId + )?.statusId === 'SCHEDULING' && + !allSelectedProposalsHaveInstrument && ( + + {`Be aware that proposal/s not assigned to an ${i18n.format( + t('instrument'), + 'lowercase' + )} will not be shown in the scheduler after changing status to "SCHEDULING"`} + + )} + {!values.selectedWorkflowStatusId && ( + + Be aware that selected proposals have different + statuses and changing status will affect all of them. + + )} + {runStatusActions && !selectedConnectionId && ( + + Please select a transition to run status actions for. + + )} + + Change status + +
-
- - )} + + ); + }}
); diff --git a/apps/frontend/src/components/proposal/ProposalTableOfficer.tsx b/apps/frontend/src/components/proposal/ProposalTableOfficer.tsx index 4dc0bf408e..af546643be 100644 --- a/apps/frontend/src/components/proposal/ProposalTableOfficer.tsx +++ b/apps/frontend/src/components/proposal/ProposalTableOfficer.tsx @@ -636,7 +636,10 @@ const ProposalTableOfficer = ({ refreshTableData(); }; - const changeStatusOnProposals = async (workflowStatus: WorkflowStatus) => { + const changeStatusOnProposals = async ( + workflowStatus: WorkflowStatus, + workflowConnectionId?: number + ) => { const proposalPks = getSelectedProposalPks(); if (workflowStatus?.workflowStatusId && proposalPks.length) { const shouldAddPluralLetter = proposalPks.length > 1 ? 's' : ''; @@ -645,6 +648,7 @@ const ProposalTableOfficer = ({ }).changeProposalsStatus({ proposalPks: proposalPks, workflowStatusId: workflowStatus.workflowStatusId, + workflowConnectionId: workflowConnectionId, }); refreshTableData(); } diff --git a/apps/frontend/src/graphql/proposal/changeProposalsStatus.graphql b/apps/frontend/src/graphql/proposal/changeProposalsStatus.graphql index 61319f93c6..2d9eaa887c 100644 --- a/apps/frontend/src/graphql/proposal/changeProposalsStatus.graphql +++ b/apps/frontend/src/graphql/proposal/changeProposalsStatus.graphql @@ -1,8 +1,9 @@ -mutation changeProposalsStatus($proposalPks: [Int!]!, $workflowStatusId: Int!) { - changeProposalsStatus( - changeProposalsStatusInput: { - proposalPks: $proposalPks - workflowStatusId: $workflowStatusId - } - ) -} +mutation changeProposalsStatus($proposalPks: [Int!]!, $workflowStatusId: Int!, $workflowConnectionId: Int) { + changeProposalsStatus( + changeProposalsStatusInput: { + proposalPks: $proposalPks + workflowStatusId: $workflowStatusId + workflowConnectionId: $workflowConnectionId + } + ) +}